Skip to content

JSON → TypeScript — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Infer a TypeScript interface tree from any JSON-serializable value.
//
// 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`.

export interface JsonToTsOptions {
  /** Name of the root interface/type. Default `Root`. */
  rootName?: string;
  /**
   * How heterogeneous arrays are combined.
   * - false (default): merge element types where possible (objects union their
   *   keys - missing keys become optional; distinct primitives form a union).
   * - true: emit a true union of distinct element types.
   */
  unionArrays?: boolean;
  /**
   * When true, object properties whose type includes `null` are marked optional.
   * e.g. `{ "a": null }` → `a?: null` instead of `a: null`.
   */
  optionalNullable?: boolean;
}

// TypeScript reserved words + built-in type names - must be quoted as keys.
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',
]);

interface PropNode {
  key: string;
  type: TypeNode;
  optional: boolean;
}

type TypeNode =
  | { kind: 'primitive'; ts: string }
  | { kind: 'object'; props: PropNode[]; signature: string; nameHint: string }
  | { kind: 'array'; of: TypeNode | null } // null element type = unknown (`unknown[]`)
  | { kind: 'union'; members: TypeNode[]; signature: string }
  | { kind: 'unknown' };

const UNKNOWN: TypeNode = { kind: 'unknown' };
const NULL_NODE: TypeNode = { kind: 'primitive', ts: 'null' };

/** PascalCase a key segment for use in interface names. */
function pascal(key: string): string {
  const parts = 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`). */
function singularize(name: string): string {
  if (name.length > 1 && name.endsWith('s') && !name.endsWith('ss')) {
    return name.slice(0, -1);
  }
  return `${name}Item`;
}

/** Sanitize a user-provided root name into a valid TS identifier (PascalCase). */
function sanitizeRoot(name: string): string {
  const cleaned = pascal(name);
  return cleaned || 'Root';
}

/** Throw on values that cannot round-trip through JSON. */
function assertJsonSerializable(value: unknown, path: string): void {
  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; // primitive (string|number|boolean) OK
  }
  if (Array.isArray(value)) {
    value.forEach((v, i) => assertJsonSerializable(v, `${path}[${i}]`));
    return;
  }
  // Reject non-plain objects (class instances, Map, Date, …) and bigints.
  const proto = Object.getPrototypeOf(value);
  if (proto === null || proto === Object.prototype) {
    for (const k of Object.keys(value)) assertJsonSerializable((value as Record<string, unknown>)[k], `${path}.${k}`);
    return;
  }
  throw new Error(`Value at ${path || 'root'} is not a plain JSON object (${proto?.constructor?.name ?? 'object'})`);
}

/** Structural signature for shape dedup (independent of assigned names). */
function signature(node: TypeNode): string {
  switch (node.kind) {
    case 'primitive':
      return node.ts;
    case 'unknown':
      return '?';
    case 'array':
      return node.of ? `[${signature(node.of)}]` : '[]';
    case 'union':
      return `(${node.members.map(signature).join('|')})`;
    case 'object': {
      const body = node.props
        .map((p) => `${p.key}${p.optional ? '?' : ''}:${signature(p.type)}`)
        .join(';');
      return `{${body}}`;
    }
  }
}

/** Does a type contain a `null` member? */
function containsNull(node: TypeNode): boolean {
  if (node.kind === 'primitive') return node.ts === 'null';
  if (node.kind === 'union') return node.members.some(containsNull);
  return false;
}

/** Dedup a list of nodes by structural signature, preserving first-seen order. */
function dedupe(nodes: TypeNode[]): TypeNode[] {
  const seen = new Set<string>();
  const out: TypeNode[] = [];
  for (const n of nodes) {
    const s = signature(n);
    if (!seen.has(s)) {
      seen.add(s);
      out.push(n);
    }
  }
  return out;
}

/** Merge object nodes: union of keys (missing → optional), recursing per key. */
function mergeObjects(objs: Extract<TypeNode, { kind: 'object' }>[], opts: Required<JsonToTsOptions>): TypeNode {
  const keyOrder: string[] = [];
  const byKey = new Map<string, Extract<TypeNode, { kind: 'object' }>[]>();
  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: PropNode[] = 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 };
  });
  const node = { kind: 'object' as const, props, signature: '', nameHint: objs[0].nameHint };
  node.signature = signature(node);
  return node;
}

/**
 * Combine a list of types into one.
 * - empty → unknown
 * - unionArrays → distinct union
 * - else → merge (objects merge keys, distinct primitives union, heterogeneous → union)
 */
function combine(nodes: TypeNode[], opts: Required<JsonToTsOptions>): TypeNode {
  if (nodes.length === 0) return UNKNOWN;
  if (opts.unionArrays) {
    const d = dedupe(nodes);
    return d.length === 1 ? d[0] : { kind: 'union', members: d, signature: signature({ kind: 'union', members: d, signature: '' }) };
  }
  const objs = nodes.filter((n): n is Extract<TypeNode, { kind: 'object' }> => n.kind === 'object');
  const arrs = nodes.filter((n): n is Extract<TypeNode, { kind: 'array' }> => n.kind === 'array');
  const prims = dedupe(nodes.filter((n) => n.kind === 'primitive'));
  const hasUnknown = nodes.some((n) => n.kind === 'unknown');

  // Pure primitive 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] : { kind: 'union', members: d, signature: signature({ kind: 'union', members: d, signature: '' }) };
  }

  // Homogeneous object array → merge into one 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: TypeNode[] = [];
  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] : { kind: 'union', members: d, signature: signature({ kind: 'union', members: d, signature: '' }) };
}

/** Recursively infer a TypeNode from a JSON value. `hint` = interface name if object. */
function infer(value: unknown, hint: string, opts: Required<JsonToTsOptions>): TypeNode {
  if (value === null) return NULL_NODE;
  const t = typeof value;
  if (t === 'string') return { kind: 'primitive', ts: 'string' };
  if (t === 'number') return { kind: 'primitive', ts: 'number' };
  if (t === 'boolean') return { kind: 'primitive', ts: '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)
  const props: PropNode[] = Object.keys(value as Record<string, unknown>).map((key) => {
    const v = (value as Record<string, unknown>)[key];
    const type = infer(v, `${hint}${pascal(key)}`, opts);
    let optional = false;
    if (opts.optionalNullable && containsNull(type)) optional = true;
    return { key, type, optional };
  });
  const node = { kind: 'object' as const, props, signature: '', nameHint: hint };
  node.signature = signature(node);
  return node;
}

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

/** Render a type node to its TS string form (parens around union array elements
 *  are added by `wrapArray` at the array site, never here). */
function renderType(node: TypeNode, names: Map<string, string>): string {
  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(' | ');
  }
}

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

/** 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 (e.g.
 *  heterogeneous object arrays under `unionArrays`), later ones get a numeric suffix. */
function collectObjects(root: TypeNode, rootName: string): { order: Extract<TypeNode, { kind: 'object' }>[]; names: Map<string, string> } {
  const names = new Map<string, string>();
  const usedNames = new Set<string>();
  const order: Extract<TypeNode, { kind: 'object' }>[] = [];
  // Only the actual root node is named `rootName`; every other object takes its
  // path-derived hint (so an array-of-objects root names its element `RootItem`,
  // not `Root`).
  const visit = (node: TypeNode, isRoot: boolean): void => {
    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 into 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 };
}

/**
 * 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: unknown, opts: JsonToTsOptions = {}): string {
  const resolved: Required<JsonToTsOptions> = {
    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');
}

function renderInterface(o: Extract<TypeNode, { kind: 'object' }>, names: Map<string, string>): string {
  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}`;
}

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 →