JSON ↔ CSV Converter — Java 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 Java implementation — the same logic the interactive tool runs, in a shareable, citable form.
// =============================================================================
// json-csv — Java 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: Java 17 — 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.
// =============================================================================
// Java'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.
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class JsonCsv {
private JsonCsv() {}
// ---------------------------------------------------------------------------
// JSON value tree
// ---------------------------------------------------------------------------
public sealed interface Json permits Null, Bool, Num, Str, Arr, Obj {}
public record Null() implements Json {}
public record Bool(boolean value) implements Json {}
public record Num(double value) implements Json {}
public record Str(String value) implements Json {}
public record Arr(List<Json> items) implements Json {}
/** Insertion-ordered; duplicates keep their first position. */
public record Obj(LinkedHashMap<String, Json> fields) implements Json {}
// ---------------------------------------------------------------------------
// Minimal JSON parser
// ---------------------------------------------------------------------------
// Compact recursive-descent parser. Sufficient for any RFC 8259 document a
// caller is likely to feed this tool.
public static final class ParseException extends RuntimeException {
public ParseException(String message) { super(message); }
}
private static final class Parser {
private final String input;
private int pos;
Parser(String input) { this.input = input; }
private void skipWs() {
while (pos < input.length()) {
char c = input.charAt(pos);
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') pos++;
else break;
}
}
private char peek() {
if (pos >= input.length()) throw new ParseException("unexpected end of input");
return input.charAt(pos);
}
private Json parseValue() {
skipWs();
char c = peek();
switch (c) {
case '{': return parseObject();
case '[': return parseArray();
case '"': return new Str(parseString());
case 't': case 'f': return parseBool();
case 'n': return parseNull();
default:
if (c == '-' || (c >= '0' && c <= '9')) return parseNumber();
throw new ParseException("unexpected character '" + c + "'");
}
}
private Obj parseObject() {
pos++; // {
LinkedHashMap<String, Json> fields = new LinkedHashMap<>();
skipWs();
if (peek() == '}') {
pos++;
return new Obj(fields);
}
while (true) {
skipWs();
if (peek() != '"') throw new ParseException("expected string key in object");
String key = parseString();
skipWs();
if (peek() != ':') throw new ParseException("expected ':' after object key");
pos++;
Json val = parseValue();
// First occurrence of a key wins, matching JS object-literal semantics.
fields.putIfAbsent(key, val);
skipWs();
char c = peek();
if (c == ',') {
pos++;
} else if (c == '}') {
pos++;
return new Obj(fields);
} else {
throw new ParseException("expected ',' or '}' in object");
}
}
}
private Arr parseArray() {
pos++; // [
List<Json> items = new ArrayList<>();
skipWs();
if (peek() == ']') {
pos++;
return new Arr(items);
}
while (true) {
items.add(parseValue());
skipWs();
char c = peek();
if (c == ',') {
pos++;
} else if (c == ']') {
pos++;
return new Arr(items);
} else {
throw new ParseException("expected ',' or ']' in array");
}
}
}
private String parseString() {
pos++; // opening quote
StringBuilder out = new StringBuilder();
while (pos < input.length()) {
char c = input.charAt(pos++);
if (c == '"') return out.toString();
if (c == '\\') {
if (pos >= input.length()) throw new ParseException("trailing escape");
char e = input.charAt(pos++);
switch (e) {
case '"': out.append('"'); break;
case '\\': out.append('\\'); break;
case '/': out.append('/'); break;
case 'n': out.append('\n'); break;
case 't': out.append('\t'); break;
case 'r': out.append('\r'); break;
case 'b': out.append('\b'); break;
case 'f': out.append('\f'); break;
case 'u': out.appendCodePoint(parseCodepoint()); break;
default: throw new ParseException("bad escape \\" + e);
}
} else {
out.append(c);
}
}
throw new ParseException("unterminated string");
}
private int parseCodepoint() {
if (pos + 4 > input.length()) throw new ParseException("short \\u escape");
int code;
try {
code = Integer.parseInt(input.substring(pos, pos + 4), 16);
} catch (NumberFormatException e) {
throw new ParseException("bad \\u escape");
}
pos += 4;
// UTF-16 surrogate pair handling.
if (code >= 0xD800 && code <= 0xDBFF
&& pos + 6 <= input.length()
&& input.startsWith("\\u", pos)) {
int lo;
try {
lo = Integer.parseInt(input.substring(pos + 2, pos + 6), 16);
} catch (NumberFormatException e) {
throw new ParseException("bad low surrogate");
}
if (lo >= 0xDC00 && lo <= 0xDFFF) {
code = 0x10000 + ((code - 0xD800) << 10) + (lo - 0xDC00);
pos += 6;
}
}
if (Character.isValidCodePoint(code)) return code;
throw new ParseException("invalid unicode codepoint");
}
private Json parseBool() {
if (input.startsWith("true", pos)) {
pos += 4;
return new Bool(true);
}
if (input.startsWith("false", pos)) {
pos += 5;
return new Bool(false);
}
throw new ParseException("invalid literal");
}
private Json parseNull() {
if (input.startsWith("null", pos)) {
pos += 4;
return new Null();
}
throw new ParseException("invalid literal");
}
private Json parseNumber() {
int start = pos;
if (pos < input.length() && input.charAt(pos) == '-') pos++;
while (pos < input.length()) {
char c = input.charAt(pos);
if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') pos++;
else break;
}
String s = input.substring(start, pos);
try {
return new Num(Double.parseDouble(s));
} catch (NumberFormatException e) {
throw new ParseException("bad number " + s);
}
}
}
/** Parse a JSON document into a {@link Json} value. Callers holding a raw
* JSON string enter here. */
public static Json parseJson(String input) {
Parser p = new Parser(input);
Json v = p.parseValue();
p.skipWs();
if (p.pos != input.length()) {
throw new ParseException("trailing data at byte " + p.pos);
}
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 static List<String> keysOf(Json v) {
if (v instanceof Arr a) {
List<String> keys = new ArrayList<>(a.items().size());
for (int i = 0; i < a.items().size(); i++) keys.add(Integer.toString(i));
return keys;
}
if (v instanceof Obj o) return new ArrayList<>(o.fields().keySet());
return List.of();
}
/** 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 static Json getField(Json v, String key) {
if (v instanceof Obj o) {
Json val = o.fields().get(key);
return val; // may be null = absent
}
if (v instanceof Arr a) {
try {
int i = Integer.parseInt(key);
if (i >= 0 && i < a.items().size()) return a.items().get(i);
} catch (NumberFormatException ignored) {
// non-numeric index -> absent
}
}
return null;
}
/** Render a number the way JS String(number) does on common inputs: Java's
* Double.toString keeps a trailing ".0" on integral doubles ("30.0"), so we
* print integral values as integers ("30"). */
private static String formatNumber(double n) {
if (n == Math.floor(n) && Math.abs(n) < 1e16) return Long.toString((long) n);
return Double.toString(n);
}
/** 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 static String jsString(Json v) {
if (v instanceof Null) return "";
if (v instanceof Bool b) return b.value() ? "true" : "false";
if (v instanceof Num n) return formatNumber(n.value());
if (v instanceof Str s) return s.value();
if (v instanceof Arr a) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < a.items().size(); i++) {
if (i > 0) sb.append(',');
sb.append(jsString(a.items().get(i)));
}
return sb.toString();
}
return "[object Object]"; // Obj
}
/** Quote a single CSV field per RFC 4180. */
private static String csvEscape(Json v) {
String s = jsString(v);
boolean needsQuoting = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == ',' || c == '"' || c == '\n' || c == '\r') {
needsQuoting = true;
break;
}
}
if (!needsQuoting) return s;
StringBuilder sb = new StringBuilder("\"");
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '"') sb.append("\"\"");
else sb.append(c);
}
return sb.append('"').toString();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** One deserialized CSV record: an insertion-ordered header -> cell map. We
* use a LinkedHashMap rather than a HashMap 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). */
public static final class CsvRow {
private final LinkedHashMap<String, String> cells;
CsvRow(LinkedHashMap<String, String> cells) { this.cells = cells; }
/** JS `row[header]` parity: "" when the header is absent. */
public String get(String header) { return cells.getOrDefault(header, ""); }
public LinkedHashMap<String, String> cells() { return cells; }
@Override public String toString() { return 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 <code>[1, 2, 3]</code>. */
public static String jsonToCsv(String text) {
Json data;
try {
data = parseJson(text);
} catch (ParseException e) {
return null;
}
// A bare value is treated as a one-row table.
List<Json> rows = data instanceof Arr a ? a.items() : List.of(data);
// Header union across object-like rows, first-seen order, de-duplicated.
List<String> headers = new ArrayList<>();
LinkedHashMap<String, Boolean> seen = new LinkedHashMap<>();
for (Json row : rows) {
for (String k : keysOf(row)) {
if (!seen.containsKey(k)) {
seen.put(k, Boolean.TRUE);
headers.add(k);
}
}
}
if (headers.isEmpty()) return null;
// First line is the (escaped) header row; subsequent lines are the rows.
List<String> lines = new ArrayList<>(rows.size() + 1);
lines.add(String.join(",", headers.stream().map(h -> csvEscape(new Str(h))).toList()));
for (Json row : rows) {
// A non-object row (null, number, string) yields an empty line: every
// header lookup on it returns null -> the empty field.
List<String> cells = new ArrayList<>(headers.size());
for (String h : headers) {
Json val = getField(row, h);
cells.add(csvEscape(val == null ? new Null() : val));
}
lines.add(String.join(",", cells));
}
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.
List<List<String>> rows = new ArrayList<>();
StringBuilder field = new StringBuilder();
List<String> row = new ArrayList<>();
boolean inQuotes = false;
int n = text.length();
int i = 0;
while (i < n) {
char ch = text.charAt(i);
if (inQuotes) {
if (ch == '"') {
// Doubled quote -> one literal quote; lone quote -> close field.
if (i + 1 < n && text.charAt(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 = new ArrayList<>();
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.length() > 0 || !row.isEmpty()) {
row.add(field.toString());
rows.add(row);
}
if (rows.isEmpty()) return List.of();
List<String> headers = rows.get(0);
List<CsvRow> out = new ArrayList<>(rows.size() - 1);
for (List<String> r : rows.subList(1, rows.size())) {
LinkedHashMap<String, String> cells = new LinkedHashMap<>();
for (int j = 0; j < headers.size(); j++) {
cells.put(headers.get(j), j < r.size() ? r.get(j) : "");
}
out.add(new CsvRow(cells));
}
return out;
}
public static void main(String[] args) {
// Small end-to-end demo so this file is runnable as a showcase.
String raw = "[{\"name\":\"Doe, John\",\"note\":\"say \\\"hi\\\"\"},{\"name\":\"Jane\",\"note\":\"plain\"}]";
String csv = jsonToCsv(raw);
System.out.println(csv);
for (CsvRow row : csvToJson(csv == null ? "" : csv)) {
System.out.println(row);
}
}
}
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 →