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 →