Skip to content

JSON → TypeScript — JavaScript source

Paste any JSON and instantly get clean, typed TypeScript interfaces - primitives, nested objects, arrays and unions, all inferred. Optional keys, reserved-word quoting, and shape dedup are handled for you. Runs 100% in your browser.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// =============================================================================
// json-to-typescript - JavaScript port
// =============================================================================
// Infer a TypeScript interface tree from any JSON-serializable value.
//
// CosmoDev polyglot showcase port of the `json-to-typescript` tool.
// Ported from src/lib/json-to-typescript.ts (the canonical TypeScript lib).
//
// Pure, deterministic, zero deps. Object values become named interfaces
// (deduplicated by structural shape); arrays become `T[]`; primitives map to
// TS primitives; literal `null` becomes `null`. See `jsonToTs`.
//
// This is display source - part of CosmoDev's polyglot tool pages.
// =============================================================================

// TypeScript reserved words + built-in type names. Any object key that appears
// here (or isn't a bareword identifier) must be emitted as a quoted string key.
const RESERVED = new Set([
  'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
  'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
  'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super',
  'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with',
  'as', 'async', 'await', 'yield', 'let', 'static', 'implements', 'interface',
  'package', 'private', 'protected', 'public', 'type', 'readonly', 'namespace',
  'abstract', 'any', 'boolean', 'never', 'number', 'object', 'string', 'symbol',
  'undefined', 'unknown', 'keyof', 'infer', 'satisfies',
]);

// ---- Type tree -------------------------------------------------------------
// A discriminated union describing a TypeScript type. `signature` is a structural
// fingerprint used to dedupe identical object shapes independent of their names.

function primitive(ts) {
  return { kind: 'primitive', ts };
}
const UNKNOWN = { kind: 'unknown' };
const NULL_NODE = { kind: 'primitive', ts: 'null' };

function newObject(props, nameHint) {
  const node = { kind: 'object', props, signature: '', nameHint };
  node.signature = signatureOf(node);
  return node;
}

function newUnion(members) {
  const node = { kind: 'union', members, signature: '' };
  node.signature = signatureOf(node);
  return node;
}

// ---- Small text helpers ----------------------------------------------------

// PascalCase a key segment for use in an interface name (`user_id` -> `UserId`).
// Empty input collapses to `Item`; a leading digit is escaped with `N`.
function pascal(key) {
  const parts = String(key).split(/[^A-Za-z0-9]+/).filter(Boolean);
  const head = parts.length
    ? parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')
    : 'Item';
  return /^[0-9]/.test(head) ? `N${head}` : head;
}

// Singularize an interface name for array-element naming (`Items` -> `Item`).
// We only trim a trailing `s` (never `ss`); otherwise append `Item`.
function singularize(name) {
  if (name.length > 1 && name.endsWith('s') && !name.endsWith('ss')) {
    return name.slice(0, -1);
  }
  return `${name}Item`;
}

// Coerce a user-supplied root name into a valid TS identifier (PascalCase).
function sanitizeRoot(name) {
  const cleaned = pascal(name);
  return cleaned || 'Root';
}

// ---- Input validation ------------------------------------------------------

// Throw on values that cannot round-trip through JSON: functions, symbols,
// undefined, and non-plain objects (class instances, Map, Date, ...).
function assertJsonSerializable(value, path) {
  if (value === null || typeof value !== 'object') {
    if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'undefined') {
      throw new Error(`Value at ${path || 'root'} is not JSON-serializable (${typeof value})`);
    }
    return; // string | number | boolean - all fine
  }
  if (Array.isArray(value)) {
    value.forEach((v, i) => assertJsonSerializable(v, `${path}[${i}]`));
    return;
  }
  // Only `{}`-created objects (or null-proto) count as plain JSON objects.
  const proto = Object.getPrototypeOf(value);
  if (proto === null || proto === Object.prototype) {
    for (const k of Object.keys(value)) {
      assertJsonSerializable(value[k], `${path}.${k}`);
    }
    return;
  }
  throw new Error(
    `Value at ${path || 'root'} is not a plain JSON object (${proto?.constructor?.name ?? 'object'})`,
  );
}

// ---- Structural signature & shape helpers ----------------------------------

// A string fingerprint of a type's shape, independent of assigned names. Object
// signatures include each key (with optionality) and recurse, so two objects
// with the same shape always share a signature.
function signatureOf(node) {
  switch (node.kind) {
    case 'primitive':
      return node.ts;
    case 'unknown':
      return '?';
    case 'array':
      return node.of ? `[${signatureOf(node.of)}]` : '[]';
    case 'union':
      return `(${node.members.map(signatureOf).join('|')})`;
    case 'object': {
      const body = node.props
        .map((p) => `${p.key}${p.optional ? '?' : ''}:${signatureOf(p.type)}`)
        .join(';');
      return `{${body}}`;
    }
  }
}

// Does a type contain a `null` leaf? Used by `optionalNullable`.
function containsNull(node) {
  if (node.kind === 'primitive') return node.ts === 'null';
  if (node.kind === 'union') return node.members.some(containsNull);
  return false;
}

// Dedupe nodes by structural signature, preserving first-seen order.
function dedupe(nodes) {
  const seen = new Set();
  const out = [];
  for (const n of nodes) {
    const s = signatureOf(n);
    if (!seen.has(s)) {
      seen.add(s);
      out.push(n);
    }
  }
  return out;
}

// ---- Combining array element types -----------------------------------------

// Merge several object nodes into one: union of keys (keys absent from some
// element become optional), recursing per key.
function mergeObjects(objs, opts) {
  const keyOrder = [];
  const byKey = new Map();
  for (const o of objs) {
    for (const p of o.props) {
      if (!byKey.has(p.key)) {
        keyOrder.push(p.key);
        byKey.set(p.key, []);
      }
      byKey.get(p.key).push(p.type);
    }
  }
  const props = keyOrder.map((key) => {
    const childTypes = byKey.get(key);
    const type = combine(childTypes, opts);
    let optional = childTypes.length < objs.length; // missing from some element
    if (opts.optionalNullable && containsNull(type)) optional = true;
    return { key, type, optional };
  });
  return newObject(props, objs[0].nameHint);
}

// Combine a list of element types into one.
//   - empty -> unknown
//   - unionArrays -> distinct union of every member
//   - else -> merge where sensible (objects merge keys; distinct primitives
//             union), and build a union only for genuinely heterogeneous input
function combine(nodes, opts) {
  if (nodes.length === 0) return UNKNOWN;
  if (opts.unionArrays) {
    const d = dedupe(nodes);
    return d.length === 1 ? d[0] : newUnion(d);
  }
  const objs = nodes.filter((n) => n.kind === 'object');
  const arrs = nodes.filter((n) => n.kind === 'array');
  const prims = dedupe(nodes.filter((n) => n.kind === 'primitive'));
  const hasUnknown = nodes.some((n) => n.kind === 'unknown');

  // Pure primitive/unknown arrays collapse: identical -> single, distinct -> union.
  if (objs.length === 0 && arrs.length === 0) {
    const members = [...prims];
    if (hasUnknown) members.push(UNKNOWN);
    const d = dedupe(members);
    return d.length === 1 ? d[0] : newUnion(d);
  }

  // Homogeneous object array -> a single merged object.
  if (objs.length > 0 && arrs.length === 0 && prims.length === 0 && !hasUnknown) {
    return mergeObjects(objs, opts);
  }

  // Otherwise build a union of the meaningful parts.
  const members = [];
  if (objs.length) members.push(mergeObjects(objs, opts));
  if (arrs.length) {
    const ofTypes = arrs.map((a) => a.of ?? UNKNOWN);
    members.push({ kind: 'array', of: ofTypes.length ? combine(ofTypes, opts) : null });
  }
  members.push(...prims);
  if (hasUnknown) members.push(UNKNOWN);
  const d = dedupe(members);
  return d.length === 1 ? d[0] : newUnion(d);
}

// ---- Inference -------------------------------------------------------------

// Recursively infer a type tree from a JSON value. `hint` is the interface
// name to use if this value is an object.
function infer(value, hint, opts) {
  if (value === null) return NULL_NODE;
  const t = typeof value;
  if (t === 'string') return primitive('string');
  if (t === 'number') return primitive('number');
  if (t === 'boolean') return primitive('boolean');
  if (Array.isArray(value)) {
    if (value.length === 0) return { kind: 'array', of: null };
    const elemHint = singularize(hint);
    const elements = value.map((e) => infer(e, elemHint, opts));
    return { kind: 'array', of: combine(elements, opts) };
  }
  // plain object (serializability already asserted upstream)
  const props = Object.keys(value).map((key) => {
    const type = infer(value[key], `${hint}${pascal(key)}`, opts);
    let optional = false;
    if (opts.optionalNullable && containsNull(type)) optional = true;
    return { key, type, optional };
  });
  return newObject(props, hint);
}

// ---- Rendering -------------------------------------------------------------

function renderKey(key) {
  if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) && !RESERVED.has(key)) return key;
  return JSON.stringify(key);
}

// Wrap an array element in parens if it would otherwise mis-parse (a union).
function wrapArray(rendered, of) {
  return of.kind === 'union' ? `(${rendered})` : rendered;
}

// Render a type node to its TS string form.
function renderType(node, names) {
  switch (node.kind) {
    case 'primitive':
      return node.ts;
    case 'unknown':
      return 'unknown';
    case 'object':
      return names.get(node.signature) ?? 'unknown';
    case 'array':
      return node.of ? `${wrapArray(renderType(node.of, names), node.of)}[]` : 'unknown[]';
    case 'union':
      return dedupe(node.members).map((m) => renderType(m, names)).join(' | ');
  }
}

// Collect every object node (deduped by shape) in first-seen order, naming each.
// Names are unique: when two distinct shapes share a path-derived hint, later
// ones get a numeric suffix. Only the actual root node takes `rootName`; an
// array-of-objects root therefore names its element `<Root>Item`.
function collectObjects(root, rootName) {
  const names = new Map();
  const usedNames = new Set();
  const order = [];
  const visit = (node, isRoot) => {
    if (node.kind === 'object') {
      if (!names.has(node.signature)) {
        let candidate = isRoot ? rootName : node.nameHint;
        if (usedNames.has(candidate)) {
          let i = 2;
          while (usedNames.has(`${candidate}${i}`)) i++;
          candidate = `${candidate}${i}`;
        }
        names.set(node.signature, candidate);
        usedNames.add(candidate);
        order.push(node);
        for (const p of node.props) visit(p.type, false);
      } else {
        // already named - still recurse to discover newly-seen nested shapes
        for (const p of node.props) visit(p.type, false);
      }
    } else if (node.kind === 'array' && node.of) {
      visit(node.of, false);
    } else if (node.kind === 'union') {
      for (const m of node.members) visit(m, false);
    }
  };
  visit(root, true);
  return { order, names };
}

function renderInterface(o, names) {
  const name = names.get(o.signature);
  if (o.props.length === 0) return `interface ${name} {}`;
  const lines = o.props.map(
    (p) => `  ${renderKey(p.key)}${p.optional ? '?' : ''}: ${renderType(p.type, names)};`,
  );
  return `interface ${name} {\n${lines.join('\n')}\n}`;
}

// ---- Public entry point -----------------------------------------------------

/**
 * Infer TypeScript interfaces from any JSON-serializable value.
 *
 * @example
 * jsonToTs({ name: 'a', age: 1 })
 * // interface Root {\n  name: string;\n  age: number;\n}
 */
export function jsonToTs(value, opts = {}) {
  const resolved = {
    rootName: sanitizeRoot(opts.rootName ?? 'Root'),
    unionArrays: opts.unionArrays ?? false,
    optionalNullable: opts.optionalNullable ?? false,
  };
  assertJsonSerializable(value, '');

  const root = infer(value, resolved.rootName, resolved);

  // Primitive / unknown / array roots emit a `type` alias; object roots emit interfaces.
  if (root.kind !== 'object') {
    if (root.kind === 'array') {
      const { order, names } = collectObjects(root, resolved.rootName);
      const ifaces = order.map((o) => renderInterface(o, names)).join('\n\n');
      const alias = `type ${resolved.rootName} = ${renderType(root, names)};`;
      return ifaces ? `${ifaces}\n\n${alias}` : alias;
    }
    return `type ${resolved.rootName} = ${renderType(root, new Map())};`;
  }

  const { order, names } = collectObjects(root, resolved.rootName);
  return order.map((o) => renderInterface(o, names)).join('\n\n');
}

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 →