Originally published at https://bestlatestapps.com/blog/generating-swift-codable-types-from-json-in-the-browser/.
Inside my JSON to Swift Codable converter: a parser that keeps 1.0 a Double, type inference that merges array items, naming that survives Swift keywords, and a decoder snippet that matches the dates it found
Every iOS project eventually gets a new endpoint, a JSON sample in a ticket, and twenty minutes of typing let properties and CodingKeys cases by hand. It's dull work, and it's also where real bugs come from: a field that's null in one response but not the sample, a price that arrives as 1.0, a date with milliseconds that .iso8601 refuses.
I built a JSON to Swift Codable converter for my site to do that typing. The interesting part isn't printing struct; it's the decisions in between. This article goes through them using the tool's actual source, swift-codable-core.js. The snippets are trimmed from it, and the generated Swift you'll see is real output: I ran the core in Node while writing this.
1. Why JSON.parse is the wrong first step
The obvious design is JSON.parse(text), then walk the result. It loses information a Swift generator needs:
- Number literals.
JSON.parse("1.0")gives the JavaScript number1, indistinguishable fromJSON.parse("1"). But an API that writes1.0is telling you the field is fractional, and if you type itInt, the first1.5breaks decoding. - Duplicate keys.
JSON.parsesilently keeps the last value. Swift'sJSONDecoderisn't guaranteed to agree, so a generator should at least warn. - Prototype-shaped keys. If you collect keys in a plain object, a JSON key called
constructoror__proto__can collide with JavaScript's own properties.
So the tool has its own small recursive-descent parser. It doesn't build JavaScript values; it builds a typed tree: {t: "obj", keys, vals}, {t: "arr", items}, {t: "str", v}, {t: "int", raw} and so on.
2. A parser that keeps what the JSON actually said
Numbers are matched against the JSON grammar and classified by their spelling:
`` javascript
function num() {
var m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(s.slice(i, i + 400));
if (!m) err("Bad number");
i += m[0].length;
var isInt = !m[2] && !m[3], big = isInt && Math.abs(Number(m[0])) > 9223372036854775807;
return { t: isInt && !big ? "int" : "dbl", raw: m[0] };
}
CODEBLOCK0 javascript
function dict() { return Object.create(null); }
function has(m, k) { return Object.prototype.hasOwnProperty.call(m, k); }
// inside obj():
var at = i, k = str();
if (has(vals, k)) dups.push({ key: k, line: s.slice(0, at).split("\n").length });
// ...
if (!has(vals, k)) keys.push(k);
vals[k] = v;
CODEBLOCK1 javascript
// seconds are required: .iso8601 (ISO8601DateFormatter, internet date-time) rejects "2026-10-25T10:00Z"
var ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
var URL_RE = /^https?:\/\/[^\s]+$/i;
CODEBLOCK2 javascript
function merge(a, b) {
if (a.k === "null" && b.k === "null") return a;
if (a.k === "null") return withOpt(b);
if (b.k === "null") return withOpt(a);
// ...
if (a.k === b.k) { /* recurse into arrays and objects, OR together date "has fraction" */ }
else if ((a.k === "int" && b.k === "dbl") || (a.k === "dbl" && b.k === "int")) r = { k: "dbl" };
else if (/* date or url mixed with str, or date with url */) r = { k: "str" };
else r = { k: "any" };
return withOptIf(r, opt);
}
CODEBLOCK3 javascript
function optional(t, k) { var f = t.fields[k]; return !!(f.opt || f.k === "null" || t.seen[k] < t.count); }
CODEBLOCK4 swift
// Real output for {"class": true, "1st": 2, "user-name": "x"}
struct Root: Codable, Sendable {
let class: Bool
let _1st: Int
let userName: String
enum CodingKeys: String, CodingKey {
case class
case _1st = "1st"
case userName = "user-name"
}
}
CODEBLOCK5 javascript
var IRREG = { people: "Person", children: "Child", men: "Man", women: "Woman", criteria: "Criterion", indices: "Index",
matrices: "Matrix", news: "News", series: "Series", species: "Species", data: "DataItem", media: "MediaItem",
status: "Status", statuses: "Status", aliases: "Alias", analyses: "Analysis", quizzes: "Quiz" };
CODEBLOCK6 swift
// Real output for {"author":{"name":"a","id":1},"editor":{"name":"b","id":2},"data":{"x":1}}
struct Root: Codable, Sendable {
let author: Author
let editor: Author
let data: DataModel
}
struct Author: Codable, Sendable {
let name: String
let id: Int
}
struct DataModel: Codable, Sendable {
let x: Int
}
CODEBLOCK7 javascript
/* JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase, as Foundation implements it: keep leading/trailing
underscores, split the rest on "_", lowercase the first part, .capitalized the others (so ID -> Id). */
function fromSnake(key) {
var m = /^(_*)(.*?)(_*)$/.exec(key), core = m[2];
if (!core) return key;
var parts = core.split("_").filter(function (p) { return p !== ""; });
if (parts.length <= 1) return key;
return m[1] + parts[0].toLowerCase() + parts.slice(1).map(function (p) {
return p.charAt(0).toUpperCase() + p.slice(1).toLowerCase(); }).join("") + m[3];
}
CODEBLOCK8 swift
// Real output for {"user_id":1,"userId":2} with convertFromSnakeCase
struct Root: Codable, Sendable {
let userId: Int
// "userId" left out: it converts to the same key as "user_id"
}
CODEBLOCK9 swift
let decoder = JSONDecoder()
// ISO-8601 with fractional seconds: .iso8601 alone rejects ".123", so use a formatter that allows it
decoder.dateDecodingStrategy = .custom { decoder in
let string = try decoder.singleValueContainer().decode(String.self)
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFraction.date(from: string) ?? ISO8601DateFormatter().date(from: string) { return date }
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date: \(string)"))
}
let value = try decoder.decode(Root.self, from: data)
CODEBLOCK10 swift
/// Any JSON value. Used for fields whose type the sample doesn't pin down.
enum JSONValue: Codable, Sendable {
case string(String), number(Double), bool(Bool), object([String: JSONValue]), array([JSONValue]), null
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() { self = .null }
else if let v = try? c.decode(Bool.self) { self = .bool(v) }
else if let v = try? c.decode(Double.self) { self = .number(v) }
else if let v = try? c.decode(String.self) { self = .string(v) }
else if let v = try? c.decode([JSONValue].self) { self = .array(v) }
else { self = .object(try c.decode([String: JSONValue].self)) }
}
// encode(to:) mirrors this with a switch
}
CODEBLOCK11 javascript
var LARGE = 200000, worker = null, job = 0;
function run() {
var text = input.value, o = opts(), my = ++job;
if (text.length > LARGE && window.Worker) {
if (!worker) worker = new Worker(el.getAttribute("data-worker"));
worker.onmessage = function (e) { if (my !== job) return; if (e.data.ok) show(e.data.result); else fail(e.data.error); };
worker.postMessage({ text: text, opts: o, core: el.getAttribute("data-core") });
return;
}
try { show(SwiftCodable.generate(text, o)); } catch (e) { fail(e.message); }
}
CODEBLOCK12 json
{
"id": 42,
"user_name": "Ana",
"created_at": "2026-10-25T06:30:00.123Z",
"avatar_url": "https://example.com/a.png",
"balance": 12.5,
"posts": [
{ "id": 1, "title": "Hello", "tags": ["swift"], "likes": 3 },
{ "id": 2, "title": null, "tags": [], "likes": 4, "pinned": true }
]
}
CODEBLOCK13 swift
struct Root: Codable, Sendable {
let id: Int
let userName: String
let createdAt: Date
let avatarUrl: URL
let balance: Double
let posts: [Post]
enum CodingKeys: String, CodingKey {
case id
case userName = "user_name"
case createdAt = "created_at"
case avatarUrl = "avatar_url"
case balance
case posts
}
}
struct Post: Codable, Sendable {
let id: Int
let title: String?
let tags: [String]
let likes: Int
let pinned: Bool?
}
`
Every rule from this article is visible: title is optional because one post has null; pinned is optional because one post lacks it; tags is [String] because the empty array merged with ["swift"]; posts became Post; the date has fractional seconds, so the decoder snippet uses the custom strategy from section 6.
Switch "Key mapping" to convertFromSnakeCase and the CodingKeys enum disappears, because every key converts to the same names, and the decoder snippet gains decoder.keyDecodingStrategy = .convertFromSnakeCase.
------------------------------------------------------------------------
## 10. Before you trust generated models: a checklist
1. **Paste the messiest real response you have**, with nulls, missing keys and empty arrays. The sample decides optionality.
2. **Check every Double that's really money** and change it to Decimal.
3. **Replace every JSONValue** with a real type or a custom init(from:).
4. **Pick one key strategy per type.** With .convertFromSnakeCase on, CodingKeys raw values must be the converted names.
5. **Remember user_ID becomes userId` under the snake-case strategy.
- Test the decoder with a fixture in a unit test, including a date with and without fractional seconds.
- Hoist date formatters out of the custom strategy closure in production code.
- Read the notes panel. Duplicate keys, renamed collisions and left-out keys are all reported there.
Wrapping up
A JSON-to-Codable generator looks like string templating, but the value is in the decisions: keep number literals, merge array elements into optionals, follow Foundation's real snake-case rule, avoid names that shadow Foundation types, and write a decoder that matches the dates you found. Those are the same decisions I'd make by hand, made the same way every time.
Try it, or browse the related tools and posts:
- JSON to Swift Codable: https://bestlatestapps.com/tools/json-to-swift-codable/
- JSON formatter and validator: https://bestlatestapps.com/tools/json-formatter/
- JSON to TypeScript and Kotlin: https://bestlatestapps.com/tools/json-to-typescript/
- Convert as you type: currency conversion in Swift: https://bestlatestapps.com/blog/convert-as-you-type-currency-conversion-in-swift/
- All of my free web tools: https://bestlatestapps.com/tools/
- My portfolio:* https://bestlatestapps.com/alok/
Next time a new endpoint lands in a ticket, paste a real response, not a hand-trimmed one; the converter is only as good as the sample it sees.
— Alok Kumar Singh*