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 →