Skip to content

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

// =============================================================================
// json-csv — Kotlin 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 maps
//
// Language: Kotlin 1.9 — standard library only; 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.
// =============================================================================

// Kotlin's stdlib has no JSON support, so this file is self-contained: a small
// recursive-descent parser, mirroring the Rust sibling snippet. Objects use a
// LinkedHashMap so key order — which is observable (it determines CSV column
// order) — is preserved, exactly as in the canonical lib.

class ParseException(message: String) : RuntimeException(message)

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

sealed interface Json
data object JNull : Json
data class JBool(val value: Boolean) : Json
data class JNum(val value: Double) : Json
data class JStr(val value: String) : Json
data class JArr(val items: List<Json>) : Json
/** Insertion-ordered; duplicates keep their first position. */
data class JObj(val fields: LinkedHashMap<String, Json>) : Json

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

private class Parser(private val input: String) {
    private var pos = 0

    private fun skipWs() {
        while (pos < input.length && input[pos] in " \t\n\r") pos++
    }

    private fun peek(): Char {
        if (pos >= input.length) throw ParseException("unexpected end of input")
        return input[pos]
    }

    fun parseValue(): Json {
        skipWs()
        return when (val c = peek()) {
            '{' -> parseObject()
            '[' -> parseArray()
            '"' -> JStr(parseString())
            't', 'f' -> parseBool()
            'n' -> parseNull()
            else -> if (c == '-' || c in '0'..'9') parseNumber()
            else throw ParseException("unexpected character '$c'")
        }
    }

    /** Position of the next unread char — used by parseJson's trailing check. */
    fun position(): Int = pos

    /** True when only whitespace remains — parseJson's trailing-data guard. */
    fun atEnd(): Boolean {
        skipWs()
        return pos >= input.length
    }

    private fun parseObject(): JObj {
        pos++ // {
        val fields = LinkedHashMap<String, Json>()
        skipWs()
        if (peek() == '}') {
            pos++
            return JObj(fields)
        }
        while (true) {
            skipWs()
            if (peek() != '"') throw ParseException("expected string key in object")
            val key = parseString()
            skipWs()
            if (peek() != ':') throw ParseException("expected ':' after object key")
            pos++
            val value = parseValue()
            // First occurrence of a key wins, matching JS object-literal semantics.
            fields.putIfAbsent(key, value)
            skipWs()
            when (peek()) {
                ',' -> pos++
                '}' -> {
                    pos++
                    return JObj(fields)
                }
                else -> throw ParseException("expected ',' or '}' in object")
            }
        }
    }

    private fun parseArray(): JArr {
        pos++ // [
        val items = mutableListOf<Json>()
        skipWs()
        if (peek() == ']') {
            pos++
            return JArr(items)
        }
        while (true) {
            items.add(parseValue())
            skipWs()
            when (peek()) {
                ',' -> pos++
                ']' -> {
                    pos++
                    return JArr(items)
                }
                else -> throw ParseException("expected ',' or ']' in array")
            }
        }
    }

    private fun parseString(): String {
        pos++ // opening quote
        val out = StringBuilder()
        while (pos < input.length) {
            val c = input[pos++]
            when {
                c == '"' -> return out.toString()
                c == '\\' -> {
                    if (pos >= input.length) throw ParseException("trailing escape")
                    when (val e = input[pos++]) {
                        '"' -> out.append('"')
                        '\\' -> out.append('\\')
                        '/' -> out.append('/')
                        'n' -> out.append('\n')
                        't' -> out.append('\t')
                        'r' -> out.append('\r')
                        'b' -> out.append('\b')
                        'f' -> out.append('\u000C')
                        'u' -> out.appendCodePoint(parseCodepoint())
                        else -> throw ParseException("bad escape \\$e")
                    }
                }
                else -> out.append(c)
            }
        }
        throw ParseException("unterminated string")
    }

    private fun parseCodepoint(): Int {
        if (pos + 4 > input.length) throw ParseException("short \\u escape")
        val code = input.substring(pos, pos + 4).toIntOrNull(16)
            ?: throw ParseException("bad \\u escape")
        pos += 4
        // UTF-16 surrogate pair handling.
        if (code in 0xD800..0xDBFF && pos + 6 <= input.length && input.startsWith("\\u", pos)) {
            val lo = input.substring(pos + 2, pos + 6).toIntOrNull(16)
            if (lo != null && lo in 0xDC00..0xDFFF) {
                pos += 6
                return 0x10000 + ((code - 0xD800) shl 10) + (lo - 0xDC00)
            }
        }
        if (Character.isValidCodePoint(code)) return code
        throw ParseException("invalid unicode codepoint")
    }

    private fun parseBool(): JBool {
        if (input.startsWith("true", pos)) {
            pos += 4
            return JBool(true)
        }
        if (input.startsWith("false", pos)) {
            pos += 5
            return JBool(false)
        }
        throw ParseException("invalid literal")
    }

    private fun parseNull(): JNull {
        if (input.startsWith("null", pos)) {
            pos += 4
            return JNull
        }
        throw ParseException("invalid literal")
    }

    private fun parseNumber(): JNum {
        val start = pos
        if (pos < input.length && input[pos] == '-') pos++
        while (pos < input.length) {
            val c = input[pos]
            if (c in '0'..'9' || c in ".eE+-") pos++ else break
        }
        val s = input.substring(start, pos)
        return JNum(s.toDoubleOrNull() ?: throw ParseException("bad number $s"))
    }
}

/** Parse a JSON document into a [Json] value. Callers holding a raw JSON
 *  string enter here. */
fun parseJson(input: String): Json {
    val p = Parser(input)
    val v = p.parseValue()
    if (!p.atEnd()) throw ParseException("trailing data at byte ${p.position()}")
    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. */
private fun keysOf(v: Json): List<String> = when (v) {
    is JArr -> v.items.indices.map(Int::toString)
    is JObj -> v.fields.keys.toList()
    else -> emptyList()
}

/** 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). */
private fun getField(v: Json, key: String): Json? = when (v) {
    is JObj -> v.fields[key]
    is JArr -> key.toIntOrNull()?.let { i -> v.items.getOrNull(i) }
    else -> null
}

/** Render a number the way JS String(number) does on common inputs: Kotlin's
 *  Double.toString keeps a trailing ".0" on integral doubles ("30.0"), so we
 *  print integral values as integers ("30"). */
private fun formatNumber(n: Double): String =
    if (n == kotlin.math.floor(n) && kotlin.math.abs(n) < 1e16) n.toLong().toString()
    else n.toString()

/** 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]". */
private fun jsString(v: Json): String = when (v) {
    JNull -> ""
    is JBool -> if (v.value) "true" else "false"
    is JNum -> formatNumber(v.value)
    is JStr -> v.value
    is JArr -> v.items.joinToString(",") { jsString(it) }
    is JObj -> "[object Object]"
}

/** Quote a single CSV field per RFC 4180. */
private fun csvEscape(v: Json): String {
    val s = jsString(v)
    return if (s.any { it == ',' || it == '"' || it == '\n' || it == '\r' }) {
        "\"${s.replace("\"", "\"\"")}\""
    } else {
        s
    }
}

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

/** One deserialized CSV record: an insertion-ordered header -> cell map. We
 *  use a LinkedHashMap rather than a plain map so duplicate/empty headers
 *  survive round-trips, exactly as in the TS lib's `Record<string, string>`
 *  indexing (later duplicate headers overwrite earlier ones, JS-style). */
class CsvRow(val cells: LinkedHashMap<String, String>) {
    /** JS `row[header]` parity: "" when the header is absent. */
    operator fun get(header: String): String = cells[header] ?: ""
    override fun toString(): String = cells.toString()
}

/** 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 `[1, 2, 3]`. */
fun jsonToCsv(text: String): String? {
    val data = try {
        parseJson(text)
    } catch (e: ParseException) {
        return null
    }

    // A bare value is treated as a one-row table.
    val rows: List<Json> = if (data is JArr) data.items else listOf(data)

    // Header union across object-like rows, first-seen order, de-duplicated.
    val headers = mutableListOf<String>()
    val seen = HashSet<String>()
    for (row in rows) {
        for (k in keysOf(row)) {
            if (seen.add(k)) headers.add(k)
        }
    }
    if (headers.isEmpty()) return null

    // First line is the (escaped) header row; subsequent lines are the rows.
    val lines = ArrayList<String>(rows.size + 1)
    lines.add(headers.joinToString(",") { csvEscape(JStr(it)) })
    for (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(headers.joinToString(",") { h -> csvEscape(getField(row, h) ?: JNull) })
    }
    return lines.joinToString("\n")
}

/** 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. */
fun csvToJson(text: String): List<CsvRow> {
    // Single-pass character-state machine. `text` is indexed by position so we
    // can look one character ahead for the doubled-quote escape.
    val rows = mutableListOf<List<String>>()
    val field = StringBuilder()
    var row = mutableListOf<String>()
    var inQuotes = false
    val n = text.length

    var i = 0
    while (i < n) {
        val 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 += 2
                    continue
                }
                inQuotes = false
            } else {
                field.append(ch)
            }
        } else if (ch == '"') {
            inQuotes = true
        } else if (ch == ',') {
            row.add(field.toString())
            field.setLength(0)
        } else if (ch == '\n') {
            row.add(field.toString())
            rows.add(row)
            row = mutableListOf()
            field.setLength(0)
        } else if (ch != '\r') {
            field.append(ch)
        }
        i++
    }

    // 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.isNotEmpty() || row.isNotEmpty()) {
        row.add(field.toString())
        rows.add(row)
    }

    if (rows.isEmpty()) return emptyList()

    val headers = rows.first()
    return rows.drop(1).map { r ->
        CsvRow(LinkedHashMap<String, String>().apply {
            headers.forEachIndexed { j, h -> put(h, r.getOrElse(j) { "" }) }
        })
    }
}

fun main() {
    // Small end-to-end demo so this file is runnable as a showcase.
    val raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]"
    val csv = jsonToCsv(raw)
    println(csv)
    csvToJson(csv ?: "").forEach(::println)
}

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 →