Skip to content

JSON ↔ CSV Converter — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

// =============================================================================
// json-csv — Zig 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 a list of row objects
//
// Language: Zig 0.13 — std.json from the standard library.
// 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.
// =============================================================================

// Zig's stdlib ships `std.json`; its ObjectMap is an insertion-ordered hash
// map, so key order — which is observable (it determines CSV column order) —
// is preserved, exactly as in the canonical lib. (The Rust sibling snippet
// embeds a minimal parser instead, as Rust's stdlib has no JSON.)

const std = @import("std");

/// One deserialized CSV record: an ordered list of (header, cell) pairs. We use
/// a list rather than a map so duplicate/empty headers survive round-trips,
/// exactly as in the TS lib's `Record<string, string>` indexing.
pub const CsvRow = std.ArrayList(Tuple);

pub const Tuple = struct { header: []const u8, cell: []const u8 };

/// Failure modes surfaced by the two public entry points.
pub const Error = error{
    /// Input was not valid JSON (jsonToCsv only).
    InvalidJson,
    /// Document yielded no object rows, hence no column headers (jsonToCsv only).
    NoHeaders,
    OutOfMemory,
};

// ---------------------------------------------------------------------------
// 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.
fn keysOf(v: std.json.Value, allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
    var keys = std.ArrayList([]const u8).init(allocator);
    switch (v) {
        .array => |items| {
            for (items.items, 0..) |_, i| {
                try keys.append(try std.fmt.allocPrint(allocator, "{d}", .{i}));
            }
        },
        .object => |map| {
            var it = map.iterator();
            while (it.next()) |entry| try keys.append(try allocator.dupe(u8, entry.key_ptr.*));
        },
        else => {},
    }
    return keys;
}

/// JS `obj[key]` parity: object lookup, or array element at a non-negative
/// integer index. Returns null when absent (which renders as the empty field).
fn getField(v: std.json.Value, key: []const u8) ?std.json.Value {
    switch (v) {
        .object => |map| return map.get(key),
        .array => |items| {
            const i = std.fmt.parseInt(usize, key, 10) catch return null;
            return if (i < items.items.len) items.items[i] else null;
        },
        else => return null,
    }
}

/// 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 Zig's shortest round-trip decimal.
fn formatNumber(writer: anytype, f: f64) !void {
    if (f == @trunc(f) and @abs(f) < 1e16) {
        try writer.print("{d}", .{@as(i64, @intFromFloat(f))});
    } else {
        try writer.print("{d}", .{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]".
fn jsString(writer: anytype, v: std.json.Value) !void {
    switch (v) {
        .null => {},
        .bool => |b| try writer.writeAll(if (b) "true" else "false"),
        // std.json parses integral literals as .integer, fractional as .float.
        .integer => |i| try writer.print("{d}", .{i}),
        .float => |f| try formatNumber(writer, f),
        // Only produced when the number overflows i64/f64 parsing; print as-is.
        .number_string => |s| try writer.writeAll(s),
        .string => |s| try writer.writeAll(s),
        .array => |items| {
            for (items.items, 0..) |item, i| {
                if (i > 0) try writer.writeAll(",");
                try jsString(writer, item);
            }
        },
        .object => try writer.writeAll("[object Object]"),
    }
}

/// Quote a single CSV field per RFC 4180.
fn csvEscape(writer: anytype, v: std.json.Value) !void {
    var s = std.ArrayList(u8).init(std.heap.page_allocator);
    defer s.deinit();
    try jsString(s.writer(), v);
    const needs_quoting = std.mem.indexOfAny(u8, s.items, ",\"\n\r") != null;
    if (!needs_quoting) {
        try writer.writeAll(s.items);
        return;
    }
    try writer.writeAll("\"");
    for (s.items) |c| {
        if (c == '"') try writer.writeAll("\"\"");
        try writer.writeAll(&[_]u8{c});
    }
    try writer.writeAll("\"");
}

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

/// Serialize a JSON document to CSV.
///
/// Returns the caller-owned CSV string. Errors with `error.NoHeaders` when the
/// document yields no object rows (and thus no headers) — e.g. `[1, 2, 3]` —
/// and `error.InvalidJson` when the input is not valid JSON. Accepts a single
/// object or an array of objects.
pub fn jsonToCsv(allocator: std.mem.Allocator, text: []const u8) Error![]u8 {
    var parsed = std.json.parseFromSlice(std.json.Value, allocator, text, .{}) catch {
        return Error.InvalidJson;
    };
    defer parsed.deinit();
    const data = parsed.value;

    // A bare value is treated as a one-row table.
    const rows: []const std.json.Value = switch (data) {
        .array => |items| items.items,
        single => &[_]std.json.Value{single},
    };

    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    // Header union across object-like rows, first-seen order, de-duplicated.
    var headers = std.ArrayList([]const u8).init(arena.allocator());
    var seen = std.StringHashMap(void).init(arena.allocator());
    for (rows) |row| {
        var row_keys = try keysOf(row, arena.allocator());
        defer row_keys.deinit();
        for (row_keys.items) |k| {
            if (!seen.contains(k)) {
                try seen.put(k, {});
                try headers.append(k);
            }
        }
    }
    if (headers.items.len == 0) return Error.NoHeaders;

    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();
    const w = out.writer();

    // First line is the (escaped) header row; subsequent lines are the rows.
    for (headers.items, 0..) |h, i| {
        if (i > 0) try w.writeAll(",");
        try csvEscape(w, std.json.Value{ .string = h });
    }
    for (rows) |row| {
        try w.writeAll("\n");
        // A non-object row (null, number, string) yields an empty line: every
        // header lookup on it returns null -> the empty field.
        for (headers.items, 0..) |h, i| {
            if (i > 0) try w.writeAll(",");
            const cell = getField(row, h) orelse std.json.Value{ .null = {} };
            try csvEscape(w, cell);
        }
    }
    return out.toOwnedSlice();
}

/// Parse RFC 4180 CSV into a list 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 list for empty input, or for input that is only a header row.
/// Caller owns the returned rows (and their strings) via the allocator.
pub fn csvToJson(allocator: std.mem.Allocator, text: []const u8) Error!std.ArrayList(CsvRow) {
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    const a = arena.allocator();

    // Single-pass character-state machine over bytes. Field content outside
    // ASCII passes through untouched (UTF-8 is transparent to the machine).
    var rows = std.ArrayList([]const []const u8).init(a);
    var field = std.ArrayList(u8).init(a);
    var row = std.ArrayList([]const u8).init(a);
    var in_quotes = false;

    var i: usize = 0;
    const n = text.len;
    while (i < n) : (i += 1) {
        const ch = text[i];
        if (in_quotes) {
            if (ch == '"') {
                // Doubled quote -> one literal quote; lone quote -> close field.
                if (i + 1 < n and text[i + 1] == '"') {
                    try field.append('"');
                    i += 1;
                    continue;
                }
                in_quotes = false;
            } else {
                try field.append(ch);
            }
        } else if (ch == '"') {
            in_quotes = true;
        } else if (ch == ',') {
            try row.append(try field.toOwnedSlice());
        } else if (ch == '\n') {
            try row.append(try field.toOwnedSlice());
            try rows.append(try row.toOwnedSlice());
            row = std.ArrayList([]const u8).init(a);
        } else if (ch != '\r') {
            try field.append(ch);
        }
    }

    // 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.items.len > 0 or row.items.len > 0) {
        try row.append(try field.toOwnedSlice());
        try rows.append(try row.toOwnedSlice());
    }

    var out = std.ArrayList(CsvRow).init(allocator);
    errdefer out.deinit();

    if (rows.items.len == 0) return out;

    const headers = rows.items[0];
    for (rows.items[1..]) |r| {
        var rec = CsvRow.init(allocator);
        errdefer {
            for (rec.items) |t| allocator.free(t.header);
            rec.deinit();
        }
        for (headers, 0..) |h, j| {
            try rec.append(.{
                .header = try allocator.dupe(u8, h),
                .cell = try allocator.dupe(u8, if (j < r.len) r[j] else ""),
            });
        }
        try out.append(rec);
    }
    return out;
}

/// Release a list produced by [csvToJson].
pub fn freeRows(allocator: std.mem.Allocator, rows: std.ArrayList(CsvRow)) void {
    for (rows.items) |rec| {
        for (rec.items) |t| allocator.free(t.header);
        rec.deinit();
    }
    rows.deinit();
}

pub fn main() !void {
    // Small end-to-end demo so this file is runnable as a showcase.
    const alloc = std.heap.page_allocator;
    const raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]";

    const csv = jsonToCsv(alloc, raw) catch |e| switch (e) {
        Error.NoHeaders => {
            std.debug.print("(no CSV produced)\n", .{});
            return;
        },
        else => return e,
    };
    defer alloc.free(csv);

    const stdout = std.io.getStdOut().writer();
    try stdout.print("{s}\n", .{csv});

    var rows = try csvToJson(alloc, csv);
    defer freeRows(alloc, rows);
    for (rows.items) |rec| {
        try stdout.print("{{", .{});
        for (rec.items, 0..) |t, i| {
            if (i > 0) try stdout.print(", ", .{});
            try stdout.print("{s}=\"{s}\"", .{ t.header, t.cell });
        }
        try stdout.print("}}\n", .{});
    }
}

test "json_to_csv quoting and empty cases" {
    const alloc = std.testing.allocator;

    const csv = try jsonToCsv(alloc, "[{\"a\":\"1\",\"b\":\"2\"},{\"a\":\"3\",\"b\":\"4\"}]");
    defer alloc.free(csv);
    try std.testing.expectEqualStrings("a,b\n1,2\n3,4", csv);

    try std.testing.expectError(Error.NoHeaders, jsonToCsv(alloc, "[1,2,3]"));
    try std.testing.expectError(Error.InvalidJson, jsonToCsv(alloc, "{oops"));

    const single = try jsonToCsv(alloc, "{\"a\":\"1\"}");
    defer alloc.free(single);
    try std.testing.expectEqualStrings("a\n1", single);
}

test "csv_to_json parses quoted fields" {
    const alloc = std.testing.allocator;

    var rows = try csvToJson(alloc, "name\n\"Doe, John\"");
    defer freeRows(alloc, rows);
    try std.testing.expectEqual(@as(usize, 1), rows.items.len);
    try std.testing.expectEqualStrings("name", rows.items[0].items[0].header);
    try std.testing.expectEqualStrings("Doe, John", rows.items[0].items[0].cell);

    var empty = try csvToJson(alloc, "");
    defer freeRows(alloc, empty);
    try std.testing.expectEqual(@as(usize, 0), empty.items.len);
}

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 →