JSON Repair — TypeScript source
Fix broken JSON - trailing commas, single quotes, unquoted keys, comments, Python constants, BOM and truncated documents - and get clean pretty-printed JSON plus a list of every repair applied. Runs entirely in your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure JSON-repair logic. No React, no DOM. Deterministic and never throws.
//
// repair() shepherds broken-but-salvageable JSON (trailing commas, single
// quotes, unquoted keys, JS/Python dialect drift, copy-paste invisible
// characters, truncation) back to text JSON.parse accepts, then pretty-prints
// it. Every pass is a small pure function so each can be tested alone.
/** Result of a repair run. */
export interface RepairResult {
/** Pretty-printed JSON. Empty string when the input could not be repaired. */
text: string;
/** Human-readable description of each repair pass that changed the text. */
fixes: string[];
/** True when at least one repair pass changed the text. */
changed: boolean;
/** True when the final text parses as JSON. */
ok: boolean;
/** Why the input could not be repaired (ok === false); null otherwise. */
error: string | null;
}
/** Cheap "should we bother repairing this" check: JSON containers start with { or [. */
export function isProbablyJson(text: string): boolean {
const t = text.trim();
return t.startsWith('{') || t.startsWith('[');
}
/** Pretty-print text that JSON.parse accepts (throws when it does not). */
export function prettyPrint(text: string): string {
return JSON.stringify(JSON.parse(text), null, 2);
}
/** Coerce a caught parse failure into a human-readable message. */
export function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : String(value);
}
/**
* [start, end] index pairs of the double-quoted strings in text (quotes
* included). An unterminated string extends to the last character.
*/
export function stringSpans(text: string): Array<[number, number]> {
const spans: Array<[number, number]> = [];
let open: number | null = null;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (open === null) {
if (ch === '"') open = i;
} else if (ch === '\\') {
i++; // skip the escaped character
} else if (ch === '"') {
spans.push([open, i]);
open = null;
}
}
if (open !== null) spans.push([open, text.length - 1]);
return spans;
}
/** True when index i sits inside one of the (ordered, non-overlapping) spans. */
function insideString(spans: Array<[number, number]>, i: number): boolean {
for (const [start, end] of spans) {
if (i >= start) {
if (i <= end) return true;
} else {
return false; // spans are ascending: i is before this one and all after it
}
}
return false;
}
// BOM, ZWSP, ZWNJ, ZWJ, word joiner - invisible characters that break JSON.parse.
const INVISIBLE_CHARS = [0xfeff, 0x200b, 0x200c, 0x200d, 0x2060].map((c) =>
String.fromCharCode(c),
);
/** Remove copy-paste invisible characters: BOM and zero-width joiners/spaces. */
export function stripInvisible(text: string): string {
let out = text;
for (const ch of INVISIBLE_CHARS) out = out.split(ch).join('');
return out;
}
/** Strip // line comments and block comments, string-aware. */
export function stripComments(text: string): string {
const spans = stringSpans(text);
const cuts: Array<[number, number]> = []; // [start, endExclusive)
for (let i = 0; i < text.length; i++) {
if (insideString(spans, i)) continue;
if (text[i] === '/' && text[i + 1] === '/') {
let end = text.indexOf('\n', i);
if (end === -1) end = text.length;
cuts.push([i, end]); // keep the newline itself
i = end - 1;
} else if (text[i] === '/' && text[i + 1] === '*') {
const close = text.indexOf('*/', i + 2);
const end = close === -1 ? text.length : close + 2;
cuts.push([i, end]);
i = end - 1;
}
}
let out = text;
for (let k = cuts.length - 1; k >= 0; k--) {
const [a, b] = cuts[k];
out = out.slice(0, a) + out.slice(b);
}
return out;
}
/**
* Convert single-quoted strings/keys to double-quoted JSON strings:
* escape inner double quotes, collapse \' to ', keep every other escape.
*/
export function singleToDoubleQuotes(text: string): string {
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === '"') {
// Copy a double-quoted string verbatim (apostrophes inside stay put).
let j = i + 1;
while (j < text.length) {
if (text[j] === '\\') j += 2;
else if (text[j] === '"') {
j++;
break;
} else {
j++;
}
}
out += text.slice(i, j);
i = j;
} else if (ch === "'") {
let body = '';
let j = i + 1;
while (j < text.length) {
const c = text[j];
if (c === '\\' && j + 1 < text.length) {
const next = text[j + 1];
body += next === "'" ? "'" : c + next;
j += 2;
} else if (c === "'") {
j++;
break;
} else if (c === '"') {
body += '\\"';
j++;
} else {
body += c;
j++;
}
}
out += `"${body}"`;
i = j;
} else {
out += ch;
i++;
}
}
return out;
}
const KEY_START = /[A-Za-z_$]/;
const KEY_CHAR = /[A-Za-z0-9_$-]/;
/** Wrap bare identifier keys ({name: 1} -> {"name": 1}), string-aware. */
export function quoteUnquotedKeys(text: string): string {
const spans = stringSpans(text);
const edits: Array<[number, number]> = [];
for (let i = 0; i < text.length; i++) {
if (insideString(spans, i)) continue;
const ch = text[i];
if (ch !== '{' && ch !== ',') continue;
let j = i + 1;
while (j < text.length && /\s/.test(text[j])) j++;
if (!KEY_START.test(text[j] ?? '')) continue;
let k = j;
while (k < text.length && KEY_CHAR.test(text[k])) k++;
let l = k;
while (l < text.length && /\s/.test(text[l])) l++;
if (text[l] === ':') edits.push([j, k]);
}
let out = text;
for (let e = edits.length - 1; e >= 0; e--) {
const [a, b] = edits[e];
out = out.slice(0, a) + `"${out.slice(a, b)}"` + out.slice(b);
}
return out;
}
const PY_CONSTANTS: Record<string, string> = {
True: 'true',
False: 'false',
None: 'null',
};
/** Rewrite bare Python constants (True/False/None) to JSON (true/false/null), string-aware. */
export function fixPythonConstants(text: string): string {
const spans = stringSpans(text);
const edits: Array<[number, number, string]> = [];
const re = /\b(True|False|None)\b/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (!insideString(spans, m.index)) {
edits.push([m.index, m.index + m[0].length, PY_CONSTANTS[m[0]]]);
}
}
let out = text;
for (let k = edits.length - 1; k >= 0; k--) {
const [a, b, rep] = edits[k];
out = out.slice(0, a) + rep + out.slice(b);
}
return out;
}
/** Remove commas followed only by whitespace and a closing } or ], string-aware. */
export function stripTrailingCommas(text: string): string {
const spans = stringSpans(text);
const cuts: number[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] !== ',' || insideString(spans, i)) continue;
let j = i + 1;
while (j < text.length && /\s/.test(text[j])) j++;
if (text[j] === '}' || text[j] === ']') cuts.push(i);
}
let out = text;
for (let k = cuts.length - 1; k >= 0; k--) {
out = out.slice(0, cuts[k]) + out.slice(cuts[k] + 1);
}
return out;
}
/**
* Recover truncated JSON: close an unterminated string, drop a dangling
* comma, give a dangling colon a null value, then close every still-open
* bracket in reverse order.
*/
export function closeTruncated(text: string): string {
let inString = false;
const stack: string[] = [];
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{' || ch === '[') stack.push(ch);
else if (ch === '}' || ch === ']') stack.pop();
}
let out = inString ? `${text}"` : text;
out = out.replace(/[\s,]+$/, '');
if (out.endsWith(':')) out += ' null';
return out + stack.reverse().map((c) => (c === '{' ? '}' : ']')).join('');
}
const PASSES: Array<[string, (s: string) => string]> = [
['Removed invisible characters (BOM / zero-width)', stripInvisible],
['Converted single quotes to double quotes', singleToDoubleQuotes],
['Stripped JavaScript comments', stripComments],
['Quoted unquoted keys', quoteUnquotedKeys],
['Converted Python constants (True/False/None)', fixPythonConstants],
['Removed trailing commas', stripTrailingCommas],
['Closed truncated brackets', closeTruncated],
];
/**
* Repair broken JSON and pretty-print the result. Never throws.
* - empty / whitespace-only / non-string → ok: false, "Input is empty"
* - input that already parses → same shape, changed: false, no fixes
* - repairable input → pretty text + a description per pass that fired
* - unrepairable input → ok: false, the engine's parse error
*/
export function repair(text: string): RepairResult {
if (typeof text !== 'string' || text.trim().length === 0) {
return { text: '', fixes: [], changed: false, ok: false, error: 'Input is empty' };
}
// Already clean: pretty-print and say so.
try {
return { text: prettyPrint(text), fixes: [], changed: false, ok: true, error: null };
} catch {
// fall through to the repair passes
}
let work = text;
const fixes: string[] = [];
for (const [label, pass] of PASSES) {
const next = pass(work);
if (next !== work) {
fixes.push(label);
work = next;
}
}
try {
return { text: prettyPrint(work), fixes, changed: fixes.length > 0, ok: true, error: null };
} catch (e) {
return {
text: '',
fixes,
changed: fixes.length > 0,
ok: false,
error: errorMessage(e),
};
}
}
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 →