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 →