Skip to content

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

// Package jsoncsv is the Go twin of CosmoDev's src/lib/csv.ts (dual source: the
// web lib is TypeScript, the CLI lib is Go — kept in lock-step). It converts
// between JSON and RFC-4180-quoted CSV. Pure + deterministic, never panics. The
// table-driven tests in json-csv_test.go share vectors with src/lib/csv.test.ts
// so the two implementations are held to the same contract.
//
// Note: unlike some CosmoDev libs, csv.ts uses no hashing/WebCrypto — it is pure
// JSON↔CSV text conversion — so this twin needs only the Go stdlib.
//
// Object key order is preserved (first-seen order drives the CSV header row),
// mirroring JS Object.keys / String() semantics. A JSON null field renders as an
// empty CSV field (field == null → ""), matching csvEscape(undefined|null).
package jsoncsv

import (
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
)

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

// jval is a JSON value that preserves object member insertion order. encoding/json
// alone (map[string]any) randomizes key order, which would scramble the CSV header
// row and break parity with the TS lib's Object.keys order, so we parse tokens
// ourselves.
type jval struct {
	kind    byte              // 'o' object, 'a' array, 's' string, 'n' number, 'b' bool, 'z' null
	str     string            // literal string (kind 's') or number text (kind 'n')
	boolean bool              // kind 'b'
	keys    []string          // kind 'o': member keys in first-seen order
	members map[string]*jval  // kind 'o'
	items   []*jval           // kind 'a'
}

// parseJSON parses one JSON value (strict: no trailing data, else error → null).
func parseJSON(s string) (*jval, error) {
	dec := json.NewDecoder(strings.NewReader(s))
	dec.UseNumber() // keep number literal text so jsString() matches JS String()
	v, err := parseValue(dec)
	if err != nil {
		return nil, err
	}
	if dec.More() {
		return nil, errors.New("json: unexpected trailing data")
	}
	return v, nil
}

func parseValue(dec *json.Decoder) (*jval, error) {
	tok, err := dec.Token()
	if err != nil {
		return nil, err
	}
	return tokenToValue(tok, dec)
}

func tokenToValue(tok json.Token, dec *json.Decoder) (*jval, error) {
	switch t := tok.(type) {
	case json.Delim:
		switch t {
		case '{':
			return parseObject(dec)
		case '[':
			return parseArray(dec)
		default:
			return nil, fmt.Errorf("json: unexpected delim %q", t)
		}
	case bool:
		return &jval{kind: 'b', boolean: t}, nil
	case string:
		return &jval{kind: 's', str: t}, nil
	case json.Number:
		return &jval{kind: 'n', str: string(t)}, nil
	case nil:
		return &jval{kind: 'z'}, nil
	default:
		return nil, fmt.Errorf("json: unexpected token %T", t)
	}
}

func parseObject(dec *json.Decoder) (*jval, error) {
	o := &jval{kind: 'o', members: map[string]*jval{}}
	for dec.More() {
		keyTok, err := dec.Token()
		if err != nil {
			return nil, err
		}
		key, ok := keyTok.(string)
		if !ok {
			return nil, errors.New("json: object key is not a string")
		}
		val, err := parseValue(dec)
		if err != nil {
			return nil, err
		}
		// Duplicate key: keep the first-seen position but let the last value win,
		// exactly like JSON.parse + Object.keys in JS.
		if _, exists := o.members[key]; !exists {
			o.keys = append(o.keys, key)
		}
		o.members[key] = val
	}
	// Consume the closing '}'.
	if _, err := dec.Token(); err != nil {
		return nil, err
	}
	return o, nil
}

func parseArray(dec *json.Decoder) (*jval, error) {
	a := &jval{kind: 'a'}
	for dec.More() {
		val, err := parseValue(dec)
		if err != nil {
			return nil, err
		}
		a.items = append(a.items, val)
	}
	if _, err := dec.Token(); err != nil { // consume ']'
		return nil, err
	}
	return a, nil
}

// objectLike reports whether v is "object-shaped" per the TS guard
// `row && typeof row === 'object'` — true for objects AND arrays, false for
// null and primitives. Arrays expose their indices as string keys, mirroring
// JS Object.keys on an array.
func (v *jval) objectLike() bool {
	return v != nil && (v.kind == 'o' || v.kind == 'a')
}

// keysOf returns the keys Object.keys would yield for an object-like value.
func (v *jval) keysOf() []string {
	if v == nil {
		return nil
	}
	switch v.kind {
	case 'o':
		return v.keys
	case 'a':
		ks := make([]string, len(v.items))
		for i := range v.items {
			ks[i] = strconv.Itoa(i)
		}
		return ks
	}
	return nil
}

// get returns the member/index value for key, or nil (missing).
func (v *jval) get(key string) *jval {
	if v == nil {
		return nil
	}
	switch v.kind {
	case 'o':
		if m, ok := v.members[key]; ok {
			return m
		}
		return nil
	case 'a':
		idx, err := strconv.Atoi(key)
		if err != nil || idx < 0 || idx >= len(v.items) {
			return nil
		}
		return v.items[idx]
	}
	return nil
}

// jsString returns the JS String(v) rendering of a value (null → "null" here;
// callers that want field semantics use fieldString, where null → "").
func jsString(v *jval) string {
	if v == nil || v.kind == 'z' {
		return "null"
	}
	switch v.kind {
	case 's', 'n':
		return v.str
	case 'b':
		if v.boolean {
			return "true"
		}
		return "false"
	case 'a':
		// JS Array.prototype.toString === join(","), with each element's
		// toString and null/undefined → "".
		parts := make([]string, len(v.items))
		for i, it := range v.items {
			if it == nil || it.kind == 'z' {
				parts[i] = ""
			} else {
				parts[i] = jsString(it)
			}
		}
		return strings.Join(parts, ",")
	case 'o':
		return "[object Object]"
	}
	return ""
}

// fieldString returns JS String(field) with the field==null → "" rule from
// csvEscape (null and missing keys both render as an empty CSV field).
func fieldString(v *jval) string {
	if v == nil || v.kind == 'z' {
		return ""
	}
	return jsString(v)
}

// ---- conversion -------------------------------------------------------------

// csvEscape quotes a CSV field when it contains a comma, double quote, or
// newline/CR, doubling any embedded quotes. Mirrors csvEscape() in csv.ts.
func csvEscape(field string) string {
	if strings.ContainsAny(field, `",`+"\n\r") {
		return `"` + strings.ReplaceAll(field, `"`, `""`) + `"`
	}
	return field
}

// JSONToCSV converts a JSON document (an object, or an array of objects) into an
// RFC-4180 CSV string with a header row. It is the Go twin of jsonToCsv() in
// src/lib/csv.ts. The boolean ok is false when the input is invalid JSON or
// yields no object keys (mirroring the TS lib's null return); otherwise true.
func JSONToCSV(j string) (csv string, ok bool) {
	v, err := parseJSON(j)
	if err != nil {
		return "", false
	}
	rows := []*jval{v}
	if v.kind == 'a' {
		rows = v.items
	}

	// Collect headers in first-seen order across all object-like rows.
	var headers []string
	seen := map[string]bool{}
	for _, row := range rows {
		if row.objectLike() {
			for _, k := range row.keysOf() {
				if !seen[k] {
					seen[k] = true
					headers = append(headers, k)
				}
			}
		}
	}
	if len(headers) == 0 {
		return "", false
	}

	lines := make([]string, 0, len(rows)+1)
	escHdr := make([]string, len(headers))
	for i, h := range headers {
		escHdr[i] = csvEscape(h)
	}
	lines = append(lines, strings.Join(escHdr, ","))

	for _, row := range rows {
		fields := make([]string, len(headers))
		for i, h := range headers {
			fields[i] = csvEscape(fieldString(row.get(h)))
		}
		lines = append(lines, strings.Join(fields, ","))
	}
	return strings.Join(lines, "\n"), true
}

// CSVToJSON parses an RFC-4180 CSV string (with a header row) into a slice of
// records. It is the Go twin of csvToJson() in src/lib/csv.ts: a hand-written
// state machine over runes handling quoted fields, doubled-quote escapes,
// embedded commas/newlines, and CRLF. Missing trailing fields on a row default
// to "" (r[i] ?? '').
func CSVToJSON(csv string) []map[string]string {
	r := []rune(csv)
	n := len(r)
	var rows [][]string
	var field strings.Builder
	var row []string
	inQ := false

	for i := 0; i < n; i++ {
		ch := r[i]
		if inQ {
			if ch == '"' {
				if i+1 < n && r[i+1] == '"' { // escaped quote
					field.WriteRune('"')
					i++
				} else {
					inQ = false
				}
			} else {
				field.WriteRune(ch)
			}
			continue
		}
		switch ch {
		case '"':
			inQ = true
		case ',':
			row = append(row, field.String())
			field.Reset()
		case '\n':
			row = append(row, field.String())
			rows = append(rows, row)
			row = nil
			field.Reset()
		case '\r':
			// ignored outside quotes (handles CRLF) — mirrors `else if (ch !== '\r')`
		default:
			field.WriteRune(ch)
		}
	}
	if field.Len() > 0 || len(row) > 0 { // flush trailing field/row
		row = append(row, field.String())
		rows = append(rows, row)
	}

	if len(rows) == 0 {
		return []map[string]string{}
	}
	headers := rows[0]
	out := make([]map[string]string, 0, len(rows)-1)
	for _, cells := range rows[1:] {
		obj := make(map[string]string, len(headers))
		for i, h := range headers {
			if i < len(cells) {
				obj[h] = cells[i]
			} else {
				obj[h] = ""
			}
		}
		out = append(out, obj)
	}
	return out
}

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 →