JSON → TypeScript — Python 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 Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
#!/usr/bin/env python3
# =============================================================================
# json-to-typescript — Python 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, stdlib only. Object values become named interfaces
# (deduplicated by structural shape); arrays become `T[]`; primitives map to
# TS primitives; literal `null` becomes `null`. See `json_to_ts`.
#
# This is display source — part of CosmoDev's polyglot tool pages.
# =============================================================================
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, List, Optional, Sequence
# Options mirror the TS lib exactly. `root_name`, `union_arrays`, and
# `optional_nullable` all have sensible defaults.
@dataclass
class JsonToTsOptions:
root_name: str = "Root"
union_arrays: bool = False
optional_nullable: bool = False
# 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.
RESERVED = frozenset(
{
"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 (modelled with subclasses) describing a TS type.
# `signature` is a structural fingerprint used to dedupe identical object
# shapes independent of the names we eventually assign.
class TypeNode:
"""Base class for all type nodes. Concrete kinds live below."""
kind: str
@dataclass
class PrimitiveNode(TypeNode):
ts: str
kind: str = field(default="primitive", init=False)
@dataclass
class UnknownNode(TypeNode):
kind: str = field(default="unknown", init=False)
@dataclass
class ArrayNode(TypeNode):
# `of is None` means the element type is unknown (`unknown[]`).
of: Optional[TypeNode]
kind: str = field(default="array", init=False)
@dataclass
class UnionNode(TypeNode):
members: List[TypeNode]
sig: str = ""
kind: str = field(default="union", init=False)
@dataclass
class PropNode:
key: str
type: TypeNode
optional: bool
@dataclass
class ObjectNode(TypeNode):
props: List[PropNode]
name_hint: str
sig: str = ""
kind: str = field(default="object", init=False)
UNKNOWN = UnknownNode()
NULL_NODE = PrimitiveNode("null")
def make_object(props: List[PropNode], name_hint: str) -> ObjectNode:
"""Build an object node and stamp its structural signature."""
node = ObjectNode(props=props, name_hint=name_hint)
node.sig = signature_of(node)
return node
def make_union(members: List[TypeNode]) -> UnionNode:
node = UnionNode(members=members)
node.sig = signature_of(node)
return node
# ---------------------------------------------------------------------------
# Small text helpers
# ---------------------------------------------------------------------------
def pascal(key: str) -> str:
"""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`` so the result is a valid TS identifier.
"""
parts = [p for p in __import__("re").split(r"[^A-Za-z0-9]+", str(key)) if p]
if parts:
head = "".join(p[:1].upper() + p[1:] for p in parts)
else:
head = "Item"
return f"N{head}" if head[:1].isdigit() else head
def singularize(name: str) -> str:
"""Singularize an interface name for array-element naming.
``Items`` -> ``Item``. We only trim a trailing ``s`` (never ``ss``);
otherwise we append ``Item``.
"""
if len(name) > 1 and name.endswith("s") and not name.endswith("ss"):
return name[:-1]
return f"{name}Item"
def sanitize_root(name: str) -> str:
"""Coerce a user-supplied root name into a valid TS identifier."""
cleaned = pascal(name)
return cleaned or "Root"
# ---------------------------------------------------------------------------
# Input validation
# ---------------------------------------------------------------------------
class NotJsonSerializableError(ValueError):
"""Raised for values that cannot round-trip through JSON."""
def assert_json_serializable(value: Any, path: str) -> None:
"""Reject values JSON cannot represent.
Python's JSON mapping: ``dict`` -> object, ``list``/``tuple`` -> array,
``str``/``int``/``float``/``bool``/``None`` -> primitives. Everything else
(custom objects, sets, bytes, ...) is rejected. ``bool`` is a subclass of
``int`` but is allowed on its own.
"""
if value is None:
return
if isinstance(value, bool):
return
if isinstance(value, (int, float, str)):
return
if isinstance(value, (list, tuple)):
for i, v in enumerate(value):
assert_json_serializable(v, f"{path}[{i}]")
return
if isinstance(value, dict):
# Only string keys survive JSON round-tripping as object keys.
for k, v in value.items():
assert_json_serializable(v, f"{path}.{k}")
return
# Fall through: anything else (set, bytes, custom class, ...) is not a
# plain JSON value.
where = path or "root"
kind = type(value).__name__
raise NotJsonSerializableError(
f"Value at {where} is not JSON-serializable ({kind})"
)
# ---------------------------------------------------------------------------
# Structural signature & shape helpers
# ---------------------------------------------------------------------------
def signature_of(node: TypeNode) -> str:
"""A structural fingerprint of a type, independent of assigned names.
Object signatures include each key (with optionality) and recurse, so two
objects with the same shape always share a signature.
"""
if isinstance(node, PrimitiveNode):
return node.ts
if isinstance(node, UnknownNode):
return "?"
if isinstance(node, ArrayNode):
return f"[{signature_of(node.of)}]" if node.of is not None else "[]"
if isinstance(node, UnionNode):
return "(" + "|".join(signature_of(m) for m in node.members) + ")"
if isinstance(node, ObjectNode):
body = ";".join(
f"{p.key}{'?' if p.optional else ''}:{signature_of(p.type)}"
for p in node.props
)
return "{" + body + "}"
raise AssertionError(f"unhandled node kind: {node!r}")
def contains_null(node: TypeNode) -> bool:
"""Does a type contain a ``null`` leaf? Used by ``optional_nullable``."""
if isinstance(node, PrimitiveNode):
return node.ts == "null"
if isinstance(node, UnionNode):
return any(contains_null(m) for m in node.members)
return False
def dedupe(nodes: Iterable[TypeNode]) -> List[TypeNode]:
"""Dedupe nodes by structural signature, preserving first-seen order."""
seen: set[str] = set()
out: List[TypeNode] = []
for n in nodes:
s = signature_of(n)
if s not in seen:
seen.add(s)
out.append(n)
return out
# ---------------------------------------------------------------------------
# Combining array element types
# ---------------------------------------------------------------------------
def merge_objects(objs: Sequence[ObjectNode], opts: JsonToTsOptions) -> ObjectNode:
"""Merge several object nodes into one.
Union of keys; keys absent from some element become optional. Each key's
types are combined recursively.
"""
key_order: List[str] = []
by_key: dict[str, List[TypeNode]] = {}
for o in objs:
for p in o.props:
if p.key not in by_key:
key_order.append(p.key)
by_key[p.key] = []
by_key[p.key].append(p.type)
props: List[PropNode] = []
for key in key_order:
child_types = by_key[key]
typ = combine(child_types, opts)
optional = len(child_types) < len(objs) # missing from some element
if opts.optional_nullable and contains_null(typ):
optional = True
props.append(PropNode(key=key, type=typ, optional=optional))
return make_object(props, objs[0].name_hint)
def combine(nodes: Sequence[TypeNode], opts: JsonToTsOptions) -> TypeNode:
"""Combine a list of element types into one.
empty -> unknown; ``union_arrays`` -> distinct union; otherwise merge where
sensible (objects merge keys, distinct primitives union) and build a union
only for genuinely heterogeneous input.
"""
if not nodes:
return UNKNOWN
if opts.union_arrays:
d = dedupe(nodes)
return d[0] if len(d) == 1 else make_union(d)
objs = [n for n in nodes if isinstance(n, ObjectNode)]
arrs = [n for n in nodes if isinstance(n, ArrayNode)]
prims = dedupe(n for n in nodes if isinstance(n, PrimitiveNode))
has_unknown = any(isinstance(n, UnknownNode) for n in nodes)
# Pure primitive/unknown arrays collapse: identical -> single, distinct -> union.
if not objs and not arrs:
members = list(prims)
if has_unknown:
members.append(UNKNOWN)
d = dedupe(members)
return d[0] if len(d) == 1 else make_union(d)
# Homogeneous object array -> a single merged object.
if objs and not arrs and not prims and not has_unknown:
return merge_objects(objs, opts)
# Otherwise build a union of the meaningful parts.
members: List[TypeNode] = []
if objs:
members.append(merge_objects(objs, opts))
if arrs:
of_types = [a.of if a.of is not None else UNKNOWN for a in arrs]
members.append(
ArrayNode(of=combine(of_types, opts) if of_types else None)
)
members.extend(prims)
if has_unknown:
members.append(UNKNOWN)
d = dedupe(members)
return d[0] if len(d) == 1 else make_union(d)
# ---------------------------------------------------------------------------
# Inference
# ---------------------------------------------------------------------------
def infer(value: Any, hint: str, opts: JsonToTsOptions) -> TypeNode:
"""Recursively infer a type tree from a JSON value.
``hint`` is the interface name to use if this value is an object.
"""
if value is None:
return NULL_NODE
# bool is a subclass of int, so it must be checked first.
if isinstance(value, bool):
return PrimitiveNode("boolean")
if isinstance(value, int):
return PrimitiveNode("number")
if isinstance(value, float):
return PrimitiveNode("number")
if isinstance(value, str):
return PrimitiveNode("string")
if isinstance(value, (list, tuple)):
if not value:
return ArrayNode(of=None)
elem_hint = singularize(hint)
elements = [infer(e, elem_hint, opts) for e in value]
return ArrayNode(of=combine(elements, opts))
if isinstance(value, dict):
# plain object (serializability already asserted upstream)
props: List[PropNode] = []
for key, v in value.items():
typ = infer(v, f"{hint}{pascal(str(key))}", opts)
optional = opts.optional_nullable and contains_null(typ)
props.append(PropNode(key=str(key), type=typ, optional=optional))
return make_object(props, hint)
raise NotJsonSerializableError(
f"Value is not JSON-serializable ({type(value).__name__})"
)
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
import re as _re
_IDENT_RE = _re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
def render_key(key: str) -> str:
"""Emit a bareword key when legal, else a quoted JSON string key."""
if _IDENT_RE.match(key) and key not in RESERVED:
return key
# json.dumps produces a valid double-quoted, escaped JSON string literal.
return __import__("json").dumps(key)
def wrap_array(rendered: str, of: TypeNode) -> str:
"""Wrap an array element in parens if it would otherwise mis-parse."""
return f"({rendered})" if isinstance(of, UnionNode) else rendered
def render_type(node: TypeNode, names: dict[str, str]) -> str:
"""Render a type node to its TS string form."""
if isinstance(node, PrimitiveNode):
return node.ts
if isinstance(node, UnknownNode):
return "unknown"
if isinstance(node, ObjectNode):
return names.get(node.sig, "unknown")
if isinstance(node, ArrayNode):
if node.of is None:
return "unknown[]"
return f"{wrap_array(render_type(node.of, names), node.of)}[]"
if isinstance(node, UnionNode):
return " | ".join(render_type(m, names) for m in dedupe(node.members))
raise AssertionError(f"unhandled node kind: {node!r}")
def collect_objects(
root: TypeNode, root_name: str
) -> tuple[List[ObjectNode], dict[str, str]]:
"""Collect every object node (deduped by shape) in first-seen order.
Names are unique: when two distinct shapes share a path-derived hint, later
ones get a numeric suffix. Only the actual root node takes ``root_name``;
an array-of-objects root therefore names its element ``<Root>Item``.
"""
names: dict[str, str] = {}
used: set[str] = set()
order: List[ObjectNode] = []
def visit(node: TypeNode, is_root: bool) -> None:
if isinstance(node, ObjectNode):
if node.sig not in names:
candidate = root_name if is_root else node.name_hint
if candidate in used:
i = 2
while f"{candidate}{i}" in used:
i += 1
candidate = f"{candidate}{i}"
names[node.sig] = candidate
used.add(candidate)
order.append(node)
for p in node.props:
visit(p.type, False)
else:
# already named — still recurse to discover new nested shapes
for p in node.props:
visit(p.type, False)
elif isinstance(node, ArrayNode) and node.of is not None:
visit(node.of, False)
elif isinstance(node, UnionNode):
for m in node.members:
visit(m, False)
visit(root, True)
return order, names
def render_interface(o: ObjectNode, names: dict[str, str]) -> str:
name = names[o.sig]
if not o.props:
return f"interface {name} {{}}"
lines = [
f" {render_key(p.key)}{'?' if p.optional else ''}: {render_type(p.type, names)};"
for p in o.props
]
return f"interface {name} {{\n" + "\n".join(lines) + "\n}"
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def json_to_ts(value: Any, opts: Optional[JsonToTsOptions] = None) -> str:
"""Infer TypeScript interfaces from any JSON-serializable value.
>>> json_to_ts({"name": "a", "age": 1})
'interface Root {\\n name: string;\\n age: number;\\n}'
"""
o = opts or JsonToTsOptions()
resolved = JsonToTsOptions(
root_name=sanitize_root(o.root_name if o.root_name is not None else "Root"),
union_arrays=o.union_arrays,
optional_nullable=o.optional_nullable,
)
assert_json_serializable(value, "")
root = infer(value, resolved.root_name, resolved)
# Primitive / unknown / array roots emit a `type` alias; object roots emit interfaces.
if not isinstance(root, ObjectNode):
if isinstance(root, ArrayNode):
order, names = collect_objects(root, resolved.root_name)
ifaces = "\n\n".join(render_interface(o2, names) for o2 in order)
alias = f"type {resolved.root_name} = {render_type(root, names)};"
return f"{ifaces}\n\n{alias}" if ifaces else alias
return f"type {resolved.root_name} = {render_type(root, {})};"
order, names = collect_objects(root, resolved.root_name)
return "\n\n".join(render_interface(o2, names) for o2 in order)
__all__ = [
"JsonToTsOptions",
"NotJsonSerializableError",
"json_to_ts",
]
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 →