Skip to content

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

<?php
// =============================================================================
// json-to-typescript — PHP 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 `jsonToTs`.
//
// Input is a PHP value as returned by `json_decode($s, true)` — `array`
// (list or assoc), `null`, `bool`, `int`, `float`, `string`.
//
// This is display source — part of CosmoDev's polyglot tool pages.
// =============================================================================

declare(strict_types=1);

namespace CosmoDev\JsonToTs;

// ---------------------------------------------------------------------------
// Options (mirror the TS lib exactly)
// ---------------------------------------------------------------------------

final class Options
{
    public function __construct(
        /** Name of the root interface/type. Default 'Root'. */
        public string $rootName = 'Root',
        /** When true, emit a true union of distinct array element types. */
        public bool $unionArrays = false,
        /** When true, properties whose type includes null become optional. */
        public bool $optionalNullable = false,
    ) {
    }
}

// ---------------------------------------------------------------------------
// Type tree
// ---------------------------------------------------------------------------
// A small class hierarchy describing an inferred TS type. `signatureOf()` (a
// free function below) is a structural fingerprint used to dedupe identical
// object shapes independent of the names we eventually assign.

interface TypeNode {}

final class PrimitiveNode implements TypeNode
{
    public function __construct(public readonly string $ts) {}
}

final class UnknownNode implements TypeNode {}

/** arrayNode: `of === null` means the element type is unknown (`unknown[]`). */
final class ArrayNode implements TypeNode
{
    public function __construct(public readonly ?TypeNode $of) {}
}

/** @param list<TypeNode> $members */
final class UnionNode implements TypeNode
{
    public function __construct(public readonly array $members) {}
}

final class PropNode
{
    public function __construct(
        public readonly string $key,
        public readonly TypeNode $type,
        public readonly bool $optional,
    ) {}
}

/** @param list<PropNode> $props */
final class ObjectNode implements TypeNode
{
    public function __construct(
        public readonly array $props,
        public readonly string $nameHint,
        public readonly string $sig,
    ) {}
}

/** Shared singletons. */
function unknownNode(): TypeNode
{
    static $u = null;
    return $u ??= new UnknownNode();
}

function nullNode(): TypeNode
{
    static $n = null;
    return $n ??= new PrimitiveNode('null');
}

// ---------------------------------------------------------------------------
// Small text helpers
// ---------------------------------------------------------------------------

/** PascalCase a key segment for an interface name (`user_id` -> `UserId`). */
function pascal(string $key): string
{
    $parts = \preg_split('/[^A-Za-z0-9]+/', $key) ?: [];
    $parts = \array_values(\array_filter($parts, '\strlen'));
    if ($parts) {
        $head = \implode('', \array_map(
            static fn (string $p) => \strtoupper($p[0]) . \substr($p, 1),
            $parts,
        ));
    } else {
        $head = 'Item';
    }
    return \ctype_digit($head[0] ?? '') ? "N{$head}" : $head;
}

/** Singularize a name for array-element naming (`Items` -> `Item`). */
function singularize(string $name): string
{
    $len = \strlen($name);
    if ($len > 1 && \str_ends_with($name, 's') && !\str_ends_with($name, 'ss')) {
        return \substr($name, 0, $len - 1);
    }
    return "{$name}Item";
}

/** Coerce a user-supplied root name into a valid TS identifier. */
function sanitizeRoot(string $name): string
{
    $cleaned = pascal($name);
    return $cleaned !== '' ? $cleaned : 'Root';
}

// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------

final class NotJsonSerializableException extends \InvalidArgumentException {}

/**
 * Reject values JSON cannot represent. Callers pass `json_decode($s, true)`
 * output, so the only "plain object" we accept is an associative array. Lists
 * (sequential int keys) recurse as arrays; everything else is rejected.
 */
function assertJsonSerializable(mixed $value, string $path): void
{
    if ($value === null) {
        return;
    }
    if (\is_bool($value) || \is_int($value) || \is_float($value) || \is_string($value)) {
        return;
    }
    $where = $path !== '' ? $path : 'root';
    if (\is_array($value)) {
        if (\array_is_list($value)) {
            foreach ($value as $i => $v) {
                assertJsonSerializable($v, "{$path}[{$i}]");
            }
            return;
        }
        foreach ($value as $k => $v) {
            assertJsonSerializable($v, "{$path}.{$k}");
        }
        return;
    }
    $kind = \get_debug_type($value);
    throw new NotJsonSerializableException(
        "Value at {$where} is not JSON-serializable ({$kind})"
    );
}

// ---------------------------------------------------------------------------
// Structural signature & shape helpers
// ---------------------------------------------------------------------------

/** A structural fingerprint of a type, independent of assigned names. */
function signatureOf(TypeNode $node): string
{
    if ($node instanceof PrimitiveNode) {
        return $node->ts;
    }
    if ($node instanceof UnknownNode) {
        return '?';
    }
    if ($node instanceof ArrayNode) {
        return $node->of !== null ? '[' . signatureOf($node->of) . ']' : '[]';
    }
    if ($node instanceof UnionNode) {
        return '(' . \implode('|', \array_map('signatureOf', $node->members)) . ')';
    }
    if ($node instanceof ObjectNode) {
        return $node->sig;
    }
    throw new \LogicException('unhandled node kind');
}

/** Does a type contain a `null` leaf? Used by `optionalNullable`. */
function containsNull(TypeNode $node): bool
{
    if ($node instanceof PrimitiveNode) {
        return $node->ts === 'null';
    }
    if ($node instanceof UnionNode) {
        foreach ($node->members as $m) {
            if (containsNull($m)) {
                return true;
            }
        }
    }
    return false;
}

/**
 * @param list<TypeNode> $nodes
 * @return list<TypeNode>
 */
function dedupe(array $nodes): array
{
    $seen = [];
    $out = [];
    foreach ($nodes as $n) {
        $s = signatureOf($n);
        if (!isset($seen[$s])) {
            $seen[$s] = true;
            $out[] = $n;
        }
    }
    return $out;
}

// ---------------------------------------------------------------------------
// Combining array element types
// ---------------------------------------------------------------------------

/**
 * Merge several object nodes into one: union of keys (keys absent from some
 * element become optional), recursing per key.
 *
 * @param list<ObjectNode> $objs
 */
function mergeObjects(array $objs, Options $opts): ObjectNode
{
    // PHP associative arrays preserve insertion order, so iterating $byKey
    // yields keys in first-seen order.
    $keyOrder = [];
    $byKey = [];
    foreach ($objs as $o) {
        foreach ($o->props as $p) {
            if (!\array_key_exists($p->key, $byKey)) {
                $keyOrder[] = $p->key;
                $byKey[$p->key] = [];
            }
            $byKey[$p->key][] = $p->type;
        }
    }

    $total = \count($objs);
    $props = [];
    foreach ($keyOrder as $key) {
        $childTypes = $byKey[$key];
        $type = combine($childTypes, $opts);
        $optional = \count($childTypes) < $total; // missing from some element
        if ($opts->optionalNullable && containsNull($type)) {
            $optional = true;
        }
        $props[] = new PropNode($key, $type, $optional);
    }
    return makeObject($props, $objs[0]->nameHint);
}

/** Build an object node and stamp its structural signature. */
function makeObject(array $props, string $nameHint): ObjectNode
{
    return new ObjectNode($props, $nameHint, signatureOfProps($props, $nameHint));
}

// signatureOf for a freshly built object whose sig we compute from props only.
function signatureOfProps(array $props, string $nameHint): string
{
    unset($nameHint); // signature is shape-only, independent of the name
    $body = \implode(';', \array_map(static function (PropNode $p): string {
        return $p->key . ($p->optional ? '?' : '') . ':' . signatureOf($p->type);
    }, $props));
    return '{' . $body . '}';
}

function makeUnion(array $members): UnionNode
{
    return new UnionNode($members);
}

/**
 * Combine a list of element types into one. empty -> unknown; `unionArrays`
 * -> distinct union; otherwise merge where sensible (objects merge keys,
 * distinct primitives union) and build a union only for genuinely
 * heterogeneous input.
 *
 * @param list<TypeNode> $nodes
 */
function combine(array $nodes, Options $opts): TypeNode
{
    if (\count($nodes) === 0) {
        return unknownNode();
    }
    if ($opts->unionArrays) {
        $d = dedupe($nodes);
        return \count($d) === 1 ? $d[0] : makeUnion($d);
    }

    $objs = [];
    $arrs = [];
    $prims = [];
    $hasUnknown = false;
    foreach ($nodes as $n) {
        if ($n instanceof ObjectNode) {
            $objs[] = $n;
        } elseif ($n instanceof ArrayNode) {
            $arrs[] = $n;
        } elseif ($n instanceof PrimitiveNode) {
            $prims[] = $n;
        } elseif ($n instanceof UnknownNode) {
            $hasUnknown = true;
        }
        // UnionNode matches none of the TS filters and is dropped (parity).
    }
    $prims = dedupe($prims);

    // Pure primitive/unknown arrays collapse: identical -> single, distinct -> union.
    if (!$objs && !$arrs) {
        $members = $prims;
        if ($hasUnknown) {
            $members[] = unknownNode();
        }
        $d = dedupe($members);
        return \count($d) === 1 ? $d[0] : makeUnion($d);
    }

    // Homogeneous object array -> a single merged object.
    if ($objs && !$arrs && !$prims && !$hasUnknown) {
        return mergeObjects($objs, $opts);
    }

    // Otherwise build a union of the meaningful parts.
    $members = [];
    if ($objs) {
        $members[] = mergeObjects($objs, $opts);
    }
    if ($arrs) {
        $ofTypes = [];
        foreach ($arrs as $a) {
            $ofTypes[] = $a->of ?? unknownNode();
        }
        $of = $ofTypes ? combine($ofTypes, $opts) : null;
        $members[] = new ArrayNode($of);
    }
    foreach ($prims as $p) {
        $members[] = $p;
    }
    if ($hasUnknown) {
        $members[] = unknownNode();
    }
    $d = dedupe($members);
    return \count($d) === 1 ? $d[0] : makeUnion($d);
}

// ---------------------------------------------------------------------------
// Inference
// ---------------------------------------------------------------------------

/** Recursively infer a type tree from a JSON value. */
function infer(mixed $value, string $hint, Options $opts): TypeNode
{
    if ($value === null) {
        return nullNode();
    }
    // bool is checked before int: in PHP `is_int(true)` is false, but be safe.
    if (\is_bool($value)) {
        return new PrimitiveNode('boolean');
    }
    if (\is_int($value) || \is_float($value)) {
        return new PrimitiveNode('number');
    }
    if (\is_string($value)) {
        return new PrimitiveNode('string');
    }
    if (\is_array($value)) {
        if (\array_is_list($value)) {
            if (\count($value) === 0) {
                return new ArrayNode(null);
            }
            $elemHint = singularize($hint);
            $elements = [];
            foreach ($value as $e) {
                $elements[] = infer($e, $elemHint, $opts);
            }
            return new ArrayNode(combine($elements, $opts));
        }
        // assoc array -> plain object (serializability already asserted)
        $props = [];
        foreach ($value as $key => $v) {
            $key = (string) $key;
            $type = infer($v, $hint . pascal($key), $opts);
            $optional = $opts->optionalNullable && containsNull($type);
            $props[] = new PropNode($key, $type, $optional);
        }
        return makeObject($props, $hint);
    }
    $kind = \get_debug_type($value);
    throw new NotJsonSerializableException("Value is not JSON-serializable ({$kind})");
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

/** TypeScript reserved words + built-in type names. */
const RESERVED = [
    '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',
];

function isBareIdent(string $s): bool
{
    if ($s === '') {
        return false;
    }
    // First char: letter, underscore, or dollar.
    if (!\preg_match('/^[A-Za-z_$]/', $s)) {
        return false;
    }
    // Rest: letter, digit, underscore, or dollar.
    return \preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $s) === 1;
}

/** Emit a bareword key when legal, else a quoted JSON string literal. */
function renderKey(string $key): string
{
    if (isBareIdent($key) && !\in_array($key, RESERVED, true)) {
        return $key;
    }
    return (string) \json_encode($key);
}

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

/** @param array<string,string> $names */
function renderType(TypeNode $node, array $names): string
{
    if ($node instanceof PrimitiveNode) {
        return $node->ts;
    }
    if ($node instanceof UnknownNode) {
        return 'unknown';
    }
    if ($node instanceof ObjectNode) {
        return $names[$node->sig] ?? 'unknown';
    }
    if ($node instanceof ArrayNode) {
        if ($node->of === null) {
            return 'unknown[]';
        }
        return wrapArray(renderType($node->of, $names), $node->of) . '[]';
    }
    if ($node instanceof UnionNode) {
        $parts = \array_map(
            static fn (TypeNode $m) => renderType($m, $names),
            dedupe($node->members),
        );
        return \implode(' | ', $parts);
    }
    return 'unknown';
}

/**
 * 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,
 * later ones get a numeric suffix. Only the actual root node takes the root
 * name; an array-of-objects root names its element `<Root>Item`.
 *
 * @return array{0: list<ObjectNode>, 1: array<string,string>}
 */
function collectObjects(TypeNode $root, string $rootName): array
{
    $names = [];   // signature -> chosen name
    $used = [];    // chosen name -> true
    $order = [];

    $visit = null;
    $visit = function (TypeNode $node, bool $isRoot) use (
        &$visit, &$names, &$used, &$order, $rootName
    ): void {
        if ($node instanceof ObjectNode) {
            if (!\array_key_exists($node->sig, $names)) {
                $candidate = $isRoot ? $rootName : $node->nameHint;
                if (isset($used[$candidate])) {
                    $i = 2;
                    while (isset($used["{$candidate}{$i}"])) {
                        $i++;
                    }
                    $candidate = "{$candidate}{$i}";
                }
                $names[$node->sig] = $candidate;
                $used[$candidate] = true;
                $order[] = $node;
                foreach ($node->props as $p) {
                    $visit($p->type, false);
                }
            } else {
                // already named — still recurse to discover new nested shapes
                foreach ($node->props as $p) {
                    $visit($p->type, false);
                }
            }
        } elseif ($node instanceof ArrayNode && $node->of !== null) {
            $visit($node->of, false);
        } elseif ($node instanceof UnionNode) {
            foreach ($node->members as $m) {
                $visit($m, false);
            }
        }
    };
    $visit($root, true);

    return [$order, $names];
}

/** @param array<string,string> $names */
function renderInterface(ObjectNode $o, array $names): string
{
    $name = $names[$o->sig];
    if (\count($o->props) === 0) {
        return "interface {$name} {}";
    }
    $lines = [];
    foreach ($o->props as $p) {
        $opt = $p->optional ? '?' : '';
        $lines[] = '  ' . renderKey($p->key) . $opt . ': ' . renderType($p->type, $names) . ';';
    }
    return "interface {$name} {\n" . \implode("\n", $lines) . "\n}";
}

// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------

/**
 * Infer TypeScript interfaces from any JSON-serializable value.
 *
 * @param mixed $value A PHP value as returned by `json_decode($s, true)`.
 */
function jsonToTs(mixed $value, ?Options $opts = null): string
{
    $o = $opts ?? new Options();
    $resolved = new Options(
        rootName: sanitizeRoot($o->rootName !== '' ? $o->rootName : 'Root'),
        unionArrays: $o->unionArrays,
        optionalNullable: $o->optionalNullable,
    );
    assertJsonSerializable($value, '');

    $root = infer($value, $resolved->rootName, $resolved);

    // Primitive / unknown / array roots emit a `type` alias; object roots emit interfaces.
    if (!$root instanceof ObjectNode) {
        if ($root instanceof ArrayNode) {
            [$order, $names] = collectObjects($root, $resolved->rootName);
            $ifaces = \implode("\n\n", \array_map(
                static fn (ObjectNode $obj) => renderInterface($obj, $names),
                $order,
            ));
            $alias = "type {$resolved->rootName} = " . renderType($root, $names) . ';';
            return $ifaces !== '' ? "{$ifaces}\n\n{$alias}" : $alias;
        }
        return "type {$resolved->rootName} = " . renderType($root, []) . ';';
    }

    [$order, $names] = collectObjects($root, $resolved->rootName);
    return \implode("\n\n", \array_map(
        static fn (ObjectNode $obj) => renderInterface($obj, $names),
        $order,
    ));
}

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 →