Skip to content

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 →