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 →