Skip to content

JSON ↔ CSV Converter — Swift source

Convert a JSON array of objects to CSV and back. Handles quoted fields, embedded commas, newlines and escaped quotes (RFC 4180). 100% in-browser.

This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// =============================================================================
// json-csv — Swift port
// =============================================================================
// Convert between JSON and RFC 4180 CSV in either direction:
//   • jsonToCsv — serialize a JSON document (object or array of objects) to CSV
//   • csvToJson — parse RFC 4180 CSV (with quoting) into row dictionaries
//
// Language: Swift 5.9 — no Foundation; minimal embedded JSON parser.
// Source: CosmoDev polyglot showcase port of json-csv,
//         ported from src/lib/csv.ts (the canonical, live TypeScript lib).
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Pure and deterministic — depends only on its inputs. RFC 4180 quoting: any
// field containing a comma, double quote, carriage return, or line feed is
// wrapped in double quotes, and each embedded quote is doubled.
//
// This is display source — part of CosmoDev's polyglot tool pages.
// =============================================================================

// Foundation's JSONSerialization returns an unordered dictionary, but key order
// is observable here (it determines CSV column order). So this file embeds a
// small recursive-descent parser instead — mirroring the Rust sibling snippet,
// which is likewise self-contained.

// ---------------------------------------------------------------------------
// JSON value tree
// ---------------------------------------------------------------------------

indirect enum Json {
    case null
    case bool(Bool)
    case number(Double)
    case str(String)
    case array([Json])
    /// Insertion-ordered (key, value) pairs; duplicates keep their first position.
    case object([(String, Json)])
}

// ---------------------------------------------------------------------------
// Minimal JSON parser
// ---------------------------------------------------------------------------
// Compact recursive-descent parser. Sufficient for any RFC 8259 document a
// caller is likely to feed this tool.

struct JsonParser {
    let chars: [Character]
    var pos = 0

    init(_ input: String) {
        chars = Array(input)
    }

    mutating func skipWs() {
        while pos < chars.count {
            switch chars[pos] {
            case " ", "\t", "\n", "\r": pos += 1
            default: return
            }
        }
    }

    func peek() -> Character? {
        pos < chars.count ? chars[pos] : nil
    }

    mutating func parseValue() throws -> Json {
        skipWs()
        guard let c = peek() else { throw ParseError("unexpected end of input") }
        switch c {
        case "{": return try parseObject()
        case "[": return try parseArray()
        case "\"": return .str(try parseString())
        case "t", "f": return try parseBool()
        case "n": return try parseNull()
        case "-", "0"..."9": return try parseNumber()
        default: throw ParseError("unexpected character \(c)")
        }
    }

    mutating func parseObject() throws -> Json {
        pos += 1 // {
        var pairs: [(String, Json)] = []
        skipWs()
        if peek() == "}" {
            pos += 1
            return .object(pairs)
        }
        while true {
            skipWs()
            guard peek() == "\"" else { throw ParseError("expected string key in object") }
            let key = try parseString()
            skipWs()
            guard peek() == ":" else { throw ParseError("expected ':' after object key") }
            pos += 1
            let val = try parseValue()
            // First occurrence of a key wins, matching JS object-literal semantics.
            if !pairs.contains(where: { $0.0 == key }) {
                pairs.append((key, val))
            }
            skipWs()
            switch peek() {
            case ",": pos += 1
            case "}":
                pos += 1
                return .object(pairs)
            default: throw ParseError("expected ',' or '}' in object")
            }
        }
    }

    mutating func parseArray() throws -> Json {
        pos += 1 // [
        var items: [Json] = []
        skipWs()
        if peek() == "]" {
            pos += 1
            return .array(items)
        }
        while true {
            items.append(try parseValue())
            skipWs()
            switch peek() {
            case ",": pos += 1
            case "]":
                pos += 1
                return .array(items)
            default: throw ParseError("expected ',' or ']' in array")
            }
        }
    }

    mutating func parseString() throws -> String {
        pos += 1 // opening quote
        var out = ""
        while let c = peek() {
            pos += 1
            switch c {
            case "\"": return out
            case "\\":
                guard let e = peek() else { throw ParseError("trailing escape") }
                pos += 1
                switch e {
                case "\"": out.append("\"")
                case "\\": out.append("\\")
                case "/": out.append("/")
                case "n": out.append("\n")
                case "t": out.append("\t")
                case "r": out.append("\r")
                case "b": out.append("\u{0008}")
                case "f": out.append("\u{000C}")
                case "u":
                    let cp = try parseCodepoint()
                    out.unicodeScalars.append(cp)
                default: throw ParseError("bad escape \\\(e)")
                }
            default: out.append(c)
            }
        }
        throw ParseError("unterminated string")
    }

    mutating func parseCodepoint() throws -> Unicode.Scalar {
        guard pos + 4 <= chars.count else { throw ParseError("short \\u escape") }
        let hex = String(chars[pos..<(pos + 4)])
        guard var code = UInt32(hex, radix: 16) else { throw ParseError("bad \\u escape") }
        pos += 4
        // UTF-16 surrogate pair handling.
        if (0xD800...0xDBFF).contains(code),
           pos + 6 <= chars.count,
           chars[pos] == "\\", chars[pos + 1] == "u" {
            let loHex = String(chars[(pos + 2)..<(pos + 6)])
            pos += 6
            if let lo = UInt32(loHex, radix: 16), (0xDC00...0xDFFF).contains(lo) {
                code = 0x10000 + ((code - 0xD800) << 10) + (lo - 0xDC00)
            }
        }
        guard let scalar = Unicode.Scalar(code) else { throw ParseError("invalid unicode codepoint") }
        return scalar
    }

    mutating func parseBool() throws -> Json {
        let rest = String(chars[pos...])
        if rest.hasPrefix("true") {
            pos += 4
            return .bool(true)
        }
        if rest.hasPrefix("false") {
            pos += 5
            return .bool(false)
        }
        throw ParseError("invalid literal")
    }

    mutating func parseNull() throws -> Json {
        guard String(chars[pos...]).hasPrefix("null") else { throw ParseError("invalid literal") }
        pos += 4
        return .null
    }

    mutating func parseNumber() throws -> Json {
        let start = pos
        if peek() == "-" { pos += 1 }
        loop: while let c = peek() {
            switch c {
            case "0"..."9", ".", "e", "E", "+", "-": pos += 1
            default: break loop
            }
        }
        let s = String(chars[start..<pos])
        guard let n = Double(s) else { throw ParseError("bad number \(s)") }
        return .number(n)
    }
}

struct ParseError: Error {
    let message: String
    init(_ message: String) { self.message = message }
}

/// Parse a JSON document into a `Json` value. Callers holding a raw JSON
/// string enter here.
func parseJson(_ input: String) throws -> Json {
    var p = JsonParser(input)
    let v = try p.parseValue()
    p.skipWs()
    guard p.pos == p.chars.count else { throw ParseError("trailing data at index \(p.pos)") }
    return v
}

// ---------------------------------------------------------------------------
// JS-equivalent value semantics
// ---------------------------------------------------------------------------
// The canonical lib uses `typeof x === 'object'` and Object.keys(x), which in
// JavaScript treat BOTH objects and arrays as "object" and expose array indices
// as string keys ("0", "1", ...). We mirror that so degenerate inputs (e.g. an
// array of arrays) produce byte-identical output to the TS.

/// Object.keys parity: array indices as strings ("0", "1", ...), or the
/// object's insertion-ordered keys. Primitives and null yield no keys.
func keysOf(_ v: Json) -> [String] {
    switch v {
    case .array(let items): return items.indices.map(String.init)
    case .object(let pairs): return pairs.map { $0.0 }
    default: return []
    }
}

/// JS `obj[key]` parity: object lookup, or array element at a non-negative
/// integer index. Returns nil when absent (which renders as the empty field).
func getField(_ v: Json, _ key: String) -> Json? {
    switch v {
    case .object(let pairs):
        return pairs.first { $0.0 == key }?.1
    case .array(let items):
        guard let i = Int(key), i >= 0, i < items.count else { return nil }
        return items[i]
    default:
        return nil
    }
}

/// Render a number the way JS String(number) does on common inputs: integral
/// doubles print without a trailing ".0" (e.g. `30.0` -> "30"); everything
/// else uses Swift's shortest round-trip form.
func formatNumber(_ f: Double) -> String {
    if f == f.rounded(), abs(f) < 1e16 {
        return String(Int64(f))
    }
    return "\(f)"
}

/// Coerce a JSON value to its display string, replicating JavaScript's
/// String(): null -> "", booleans -> "true"/"false", numbers -> decimal form,
/// arrays -> elements joined by "," (so a comma-bearing cell re-quotes), and
/// objects -> "[object Object]".
func jsString(_ v: Json) -> String {
    switch v {
    case .null: return ""
    case .bool(let b): return b ? "true" : "false"
    case .number(let n): return formatNumber(n)
    case .str(let s): return s
    case .array(let items): return items.map(jsString).joined(separator: ",")
    case .object: return "[object Object]"
    }
}

/// Quote a single CSV field per RFC 4180.
func csvEscape(_ v: Json) -> String {
    let s = jsString(v)
    if s.contains(where: { $0 == "," || $0 == "\"" || $0 == "\n" || $0 == "\r" }) {
        // Each embedded quote is doubled, then the whole field is wrapped.
        var doubled = ""
        for ch in s {
            doubled.append(ch == "\"" ? "\"\"" : String(ch))
        }
        return "\"" + doubled + "\""
    }
    return s
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// One deserialized CSV record: an ordered list of (header, cell) pairs. We use
/// an array rather than a Dictionary so duplicate/empty headers survive
/// round-trips, exactly as in the TS lib's `Record<string, string>` indexing.
typealias CsvRow = [(String, String)]

/// Serialize a JSON document to CSV.
///
/// Returns `.success(String)` on success, `.success(nil)` when the document
/// yields no object rows (and thus no headers) — e.g. `[1, 2, 3]` — and
/// `.failure` when the input is not valid JSON. Accepts a single object or an
/// array of objects.
func jsonToCsv(_ input: String) -> Result<String?, Error> {
    let data: Json
    do {
        data = try parseJson(input)
    } catch {
        return .failure(error)
    }

    // A bare value is treated as a one-row table.
    let rows: [Json]
    if case .array(let items) = data {
        rows = items
    } else {
        rows = [data]
    }

    // Header union across object-like rows, first-seen order, de-duplicated.
    var headers: [String] = []
    var seen = Set<String>()
    for row in rows {
        for k in keysOf(row) where !seen.contains(k) {
            seen.insert(k)
            headers.append(k)
        }
    }
    if headers.isEmpty {
        return .success(nil)
    }

    var lines: [String] = []
    lines.append(headers.map { csvEscape(.str($0)) }.joined(separator: ","))
    for row in rows {
        // A non-object row (null, number, string) yields an empty line: every
        // header lookup on it returns nil -> the empty field.
        let cells = headers.map { h in
            csvEscape(getField(row, h) ?? .null)
        }
        lines.append(cells.joined(separator: ","))
    }
    return .success(lines.joined(separator: "\n"))
}

/// Parse RFC 4180 CSV into an array of rows keyed by the first row.
///
/// Handles quoted fields, doubled-quote escapes, and embedded
/// commas/newlines; bare carriage returns outside quotes are ignored. Returns
/// an empty array for empty input, or for input that is only a header row.
func csvToJson(_ csv: String) -> [CsvRow] {
    // Single-pass character-state machine over Characters, so multi-byte field
    // content is preserved. CSV structural characters are ASCII.
    var rows: [[String]] = []
    var field = ""
    var row: [String] = []
    var inQuotes = false

    let chars = Array(csv)
    var i = 0
    while i < chars.count {
        let ch = chars[i]
        if inQuotes {
            if ch == "\"" {
                // Doubled quote -> one literal quote; lone quote -> close field.
                if i + 1 < chars.count && chars[i + 1] == "\"" {
                    field.append("\"")
                    i += 2
                    continue
                }
                inQuotes = false
            } else {
                field.append(ch)
            }
        } else if ch == "\"" {
            inQuotes = true
        } else if ch == "," {
            row.append(field)
            field = ""
        } else if ch == "\n" {
            row.append(field)
            rows.append(row)
            row = []
            field = ""
        } else if ch != "\r" {
            field.append(ch)
        }
        i += 1
    }

    // Flush a trailing row only when there is pending content. Input that ended
    // with a newline already flushed; this guard avoids an empty final row.
    if !field.isEmpty || !row.isEmpty {
        row.append(field)
        rows.append(row)
    }

    guard let headers = rows.first else { return [] }

    return rows.dropFirst().map { r in
        headers.enumerated().map { i, h in
            (h, i < r.count ? r[i] : "")
        }
    }
}

// Small end-to-end demo so this file is runnable as a showcase.
let raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]"
switch jsonToCsv(raw) {
case .success(.some(let csv)):
    print(csv)
    for row in csvToJson(csv) {
        print(row)
    }
case .success(.none):
    print("(no CSV produced)")
case .failure(let error):
    let message = error is ParseError ? (error as! ParseError).message : "\(error)"
    print("error: \(message)")
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →