Generating Swift Codable Types from JSON in the Browser: Number Literals, Optional Merging and the convertFromSnakeCase Rules

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…

By Alok Singh (@aloksinghdev.bsky.social)
Published:

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:

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.


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:

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*