Skip to content

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 →