Skip to content

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

// =============================================================================
// json-csv — C# 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 records
//
// Language: C# 12 — System.Text.Json from the .NET runtime.
// 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.
// =============================================================================

// JSON parsing uses System.Text.Json (in the .NET runtime — the C# equivalent
// of an ecosystem crate in Rust). JsonDocument preserves property order, which
// matters here: key order is observable (it determines CSV column order).

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;

namespace CosmoDev.JsonCsv;

public static class JsonCsv
{
    // ---------------------------------------------------------------------------
    // Number formatting (JS String(number) parity)
    // ---------------------------------------------------------------------------
    // C#'s `30.0.ToString()` is "30" (like JavaScript's `String(30.0)`), and
    // "R" gives the shortest round-trip form for non-integral doubles. The
    // explicit integral branch documents the parity and dodges culture drift.
    static string FormatNumber(double n)
    {
        if (n == Math.Floor(n) && Math.Abs(n) < 1e16)
        {
            return ((long)n).ToString(System.Globalization.CultureInfo.InvariantCulture);
        }
        return n.ToString("R", System.Globalization.CultureInfo.InvariantCulture);
    }

    // ---------------------------------------------------------------------------
    // 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.
    static IEnumerable<string> KeysOf(JsonElement v)
    {
        switch (v.ValueKind)
        {
            case JsonValueKind.Array:
                for (var i = 0; i < v.GetArrayLength(); i++)
                {
                    yield return i.ToString();
                }
                break;
            case JsonValueKind.Object:
                foreach (var p in v.EnumerateObject())
                {
                    yield return p.Name;
                }
                break;
        }
    }

    /// 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).
    static JsonElement? GetField(JsonElement v, string key)
    {
        switch (v.ValueKind)
        {
            case JsonValueKind.Object:
                foreach (var p in v.EnumerateObject())
                {
                    if (p.Name == key)
                    {
                        return p.Value;
                    }
                }
                return null;
            case JsonValueKind.Array:
                if (int.TryParse(key, out var i) && i >= 0 && i < v.GetArrayLength())
                {
                    return v[i];
                }
                return null;
            default:
                return null;
        }
    }

    /// 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]".
    static string JsString(JsonElement v) => v.ValueKind switch
    {
        JsonValueKind.Null or JsonValueKind.Undefined => "",
        JsonValueKind.True => "true",
        JsonValueKind.False => "false",
        JsonValueKind.Number => FormatNumber(v.GetDouble()),
        JsonValueKind.String => v.GetString() ?? "",
        JsonValueKind.Array => string.Join(",", v.EnumerateArray().Select(JsString)),
        _ => "[object Object]", // Object (or anything else)
    };

    /// Quote a single CSV field per RFC 4180.
    static string CsvEscape(JsonElement v)
    {
        var s = JsString(v);
        if (s.IndexOfAny([',', '"', '\n', '\r']) >= 0)
        {
            return $"\"{s.Replace("\"", "\"\"")}\"";
        }
        return s;
    }

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

    /// One deserialized CSV record: an ordered list of (header, cell) pairs. We
    /// use a list rather than a dictionary so duplicate/empty headers survive
    /// round-trips, exactly as in the TS lib's `Record<string, string>` indexing.
    public sealed record CsvRow(IReadOnlyList<(string Header, string Cell)> Fields)
    {
        public string this[string header]
        {
            get
            {
                var f = Fields.FirstOrDefault(x => x.Header == header);
                return f == default ? "" : f.Cell;
            }
        }
    }

    /// Serialize a JSON document to CSV.
    ///
    /// Accepts a single object or an array of objects. Returns null on invalid
    /// JSON, or when the document yields no object rows (and thus no column
    /// headers) — e.g. a bare array of primitives such as <c>[1, 2, 3]</c>.
    public static string? JsonToCsv(string text)
    {
        JsonDocument doc;
        try
        {
            doc = JsonDocument.Parse(text);
        }
        catch (JsonException)
        {
            return null;
        }

        using (doc)
        {
            var data = doc.RootElement;

            // A bare value is treated as a one-row table.
            var rows = data.ValueKind == JsonValueKind.Array
                ? data.EnumerateArray().ToList()
                : [data];

            // Header union across object-like rows, first-seen order, de-duplicated.
            var headers = new List<string>();
            var seen = new HashSet<string>();
            foreach (var row in rows)
            {
                foreach (var k in KeysOf(row))
                {
                    if (seen.Add(k))
                    {
                        headers.Add(k);
                    }
                }
            }
            if (headers.Count == 0)
            {
                return null;
            }

            // First line is the (escaped) header row; subsequent lines are the rows.
            var lines = new List<string>(rows.Count + 1)
            {
                string.Join(",", headers.Select(h => CsvEscape((JsonElement)h))),
            };
            foreach (var row in rows)
            {
                // A non-object row (null, number, string) yields an empty line:
                // every header lookup on it returns null -> the empty field.
                lines.Add(string.Join(",", headers.Select(h =>
                    CsvEscape(GetField(row, h) ?? default)));
            }
            return string.Join("\n", lines);
        }
    }

    /// 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.
    public static List<CsvRow> CsvToJson(string text)
    {
        // Single-pass character-state machine. `text` is indexed by position so
        // we can look one character ahead for the doubled-quote escape.
        var rows = new List<List<string>>();
        var field = new StringBuilder();
        var row = new List<string>();
        var inQuotes = false;
        var n = text.Length;

        for (var i = 0; i < n; i++)
        {
            var ch = text[i];
            if (inQuotes)
            {
                if (ch == '"')
                {
                    // Doubled quote -> one literal quote; lone quote -> close field.
                    if (i + 1 < n && text[i + 1] == '"')
                    {
                        field.Append('"');
                        i++;
                        continue;
                    }
                    inQuotes = false;
                }
                else
                {
                    field.Append(ch);
                }
            }
            else if (ch == '"')
            {
                inQuotes = true;
            }
            else if (ch == ',')
            {
                row.Add(field.ToString());
                field.Clear();
            }
            else if (ch == '\n')
            {
                row.Add(field.ToString());
                rows.Add(row);
                row = [];
                field.Clear();
            }
            else if (ch != '\r')
            {
                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.Length > 0 || row.Count > 0)
        {
            row.Add(field.ToString());
            rows.Add(row);
        }

        if (rows.Count == 0)
        {
            return [];
        }

        var headers = rows[0];
        var result = new List<CsvRow>(rows.Count - 1);
        foreach (var r in rows.Skip(1))
        {
            CsvRow rec = new(headers.Select((h, i) => (h, i < r.Count ? r[i] : "")).ToList());
            result.Add(rec);
        }
        return result;
    }
}

public static class JsonCsvDemo
{
    public static void Main()
    {
        // Small end-to-end demo so this file is runnable as a showcase.
        const string raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]";
        var csv = JsonCsv.JsonToCsv(raw);
        Console.WriteLine(csv);
        foreach (var row in JsonCsv.CsvToJson(csv ?? ""))
        {
            Console.WriteLine(string.Join(", ", row.Fields.Select(f => $"{f.Header}={f.Cell}")));
        }
    }
}

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 →