Skip to content

JSON to Zod Schema — Python source

Generate Zod validation schemas from JSON. Infers z.string, z.number, z.boolean, z.object, z.array, z.null, and z.union for mixed arrays.

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

"""
json-to-zod — Python polyglot showcase port.

Recursively infers a Zod schema string from a JSON value. Mixed-type arrays
collapse to z.union(...); plain objects become z.object({...}); empty arrays
and objects fall back to z.array(z.unknown()) / z.object({}). The function
never raises — JSON parse failures and inference problems are returned as
{"ok": False, "error": str}.

Ported from src/lib/jsonToZod.ts (CosmoDev).
Display source — part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).
"""

from __future__ import annotations

import json
import re
from typing import Any

# Drop every character that is not a valid identifier byte, then turn leading
# digits into underscores (a JS identifier cannot start with a digit).
_INVALID_VAR_CHARS = re.compile(r"[^A-Za-z0-9_$]")
_LEADING_DIGITS = re.compile(r"^[0-9]+")


def _sanitize_var_name(name: str) -> str:
    """Reduce an arbitrary string to a usable JS variable name."""
    cleaned = _INVALID_VAR_CHARS.sub("", name)
    cleaned = _LEADING_DIGITS.sub(lambda m: "_" * len(m.group()), cleaned)
    return cleaned or "schema"


def _pad(s: str, depth: int) -> str:
    """Indent every non-empty line by ``depth`` spaces; leave blanks alone."""
    prefix = " " * depth
    return "\n".join((prefix + line) if line else line for line in s.split("\n"))


def _distinct(seq: list[str]) -> list[str]:
    """Order-preserving de-duplication, mirroring JS ``[...new Set(seq)]``."""
    seen: set[str] = set()
    out: list[str] = []
    for item in seq:
        if item not in seen:
            seen.add(item)
            out.append(item)
    return out


def infer_zod(value: Any, indent: int = 2) -> str:
    """Infer a Zod schema string for ``value`` at the given indentation depth."""
    if value is None:
        return "z.null()"
    # bool MUST be checked before int: in Python ``isinstance(True, int)`` is
    # True, whereas JSON treats booleans and numbers as distinct types.
    if isinstance(value, bool):
        return "z.boolean()"
    if isinstance(value, (int, float)):
        return "z.number()"
    if isinstance(value, str):
        return "z.string()"
    if isinstance(value, list):
        if not value:
            return "z.array(z.unknown())"

        types = [_infer_zod(e, indent + 2) for e in value]
        distinct = _distinct(types)

        # Single shared element type → z.array(T). Otherwise z.union([...]).
        # The union renders the *full* ``types`` list (duplicates included) to
        # match the TypeScript reference byte-for-byte.
        if len(distinct) == 1:
            inner = distinct[0]
        else:
            inner = (
                "z.union([\n"
                + _pad(",\n".join(types), indent + 2)
                + "\n"
                + _pad("", indent)
                + "])"
            )
        return f"z.array({inner})"

    if isinstance(value, dict):
        pad0 = " " * indent
        pad1 = " " * (indent + 2)
        # dict preserves insertion order in CPython 3.7+, so emitted fields
        # follow source order for the (common) case of string keys.
        if not value:
            return "z.object({})"
        fields = "\n".join(
            f"{pad1}{key}: {_infer_zod(v, indent + 2)},"
            for key, v in value.items()
        )
        return f"z.object({{\n{fields}\n{pad0}}})"

    # Unreachable for valid JSON, kept as a deterministic safety net.
    return "z.unknown()"


# Internal alias so the recursive calls above resolve even if a user shadows
# the public name via ``from module import infer_zod as ...``.
_infer_zod = infer_zod


def json_to_zod(json_string: str, root_name: str | None = None) -> dict[str, Any]:
    """
    Convert a JSON string into a ``const NAME = <zod schema>;`` declaration.

    Returns a dict with ``ok``, ``code`` and ``error`` keys — never raises.
    """
    try:
        value = json.loads(json_string)
    except json.JSONDecodeError as exc:
        return {"ok": False, "code": "", "error": str(exc)}

    try:
        name = _sanitize_var_name(root_name or "Root")
        return {
            "ok": True,
            "code": f"const {name} = {_infer_zod(value, 2)};",
            "error": None,
        }
    except Exception as exc:  # pragma: no cover - defensive, mirrors TS try/catch
        return {"ok": False, "code": "", "error": str(exc)}

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 →