Skip to content

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

<?php
/**
 * json-to-zod — PHP 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 throws — JSON parse failures and inference problems are returned as
 * ['ok' => false, 'error' => ...].
 *
 * Ported from src/lib/jsonToZod.ts (CosmoDev).
 * Display source — part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).
 */

declare(strict_types=1);

/**
 * Turn an arbitrary string into a valid JS identifier:
 *   - drop every char outside [A-Za-z0-9_$]
 *   - replace each leading digit with '_' (identifiers can't start with a digit)
 *   - default to "schema" when nothing usable remains.
 */
function sanitizeVarName(string $name): string {
    $cleaned = preg_replace('/[^A-Za-z0-9_$]/', '', $name);
    $cleaned = preg_replace_callback('/^[0-9]+/', fn(array $m) => str_repeat('_', strlen($m[0])), $cleaned);
    return $cleaned === '' ? 'schema' : $cleaned;
}

/**
 * Indent every non-empty line of $s by $depth spaces. Blank lines are left
 * untouched so they don't pick up trailing whitespace.
 */
function pad(string $s, int $depth): string {
    $prefix = str_repeat(' ', $depth);
    $lines = explode("\n", $s);
    foreach ($lines as &$line) {
        if ($line !== '') {
            $line = $prefix . $line;
        }
    }
    unset($line);
    return implode("\n", $lines);
}

/**
 * Order-preserving de-duplication, mirroring JS [...new Set(seq)]. array_unique
 * alone would reorder numeric-looking strings under SORT_REGULAR; SORT_STRING
 * keeps string equality semantics intact.
 */
function distinctStrings(array $seq): array {
    $seen = [];
    $out = [];
    foreach ($seq as $item) {
        if (!in_array($item, $seen, true)) {
            $seen[] = $item;
            $out[] = $item;
        }
    }
    return $out;
}

/**
 * Infer a Zod schema string for $value at the given indentation depth.
 *
 * Note: JSON is decoded with assoc=false (objects as stdClass) so that empty
 * objects ({}) and empty arrays ([]) stay distinguishable — both decode to an
 * empty PHP array in assoc mode.
 */
function inferZod(mixed $value, int $indent = 2): string {
    if ($value === null) {
        return 'z.null()';
    }
    if (is_bool($value)) {
        return 'z.boolean()';
    }
    if (is_int($value) || is_float($value)) {
        return 'z.number()';
    }
    if (is_string($value)) {
        return 'z.string()';
    }

    if (is_array($value)) {
        // JSON arrays decode to PHP list-style arrays (sequential int keys).
        if (count($value) === 0) {
            return 'z.array(z.unknown())';
        }

        $types = array_map(fn($e) => inferZod($e, $indent + 2), array_values($value));
        $distinct = distinctStrings($types);

        // Single shared element type → z.array(T). Multiple → z.union([...]).
        // The union renders the *full* $types list (duplicates included) to
        // match the TypeScript reference byte-for-byte.
        if (count($distinct) === 1) {
            $inner = $distinct[0];
        } else {
            $inner = "z.union([\n"
                   . pad(implode(",\n", $types), $indent + 2)
                   . "\n"
                   . pad('', $indent)
                   . '])';
        }
        return "z.array({$inner})";
    }

    if ($value instanceof \stdClass) {
        // get_object_vars preserves source insertion order in modern PHP.
        $entries = get_object_vars($value);
        if (count($entries) === 0) {
            return 'z.object({})';
        }
        $pad0 = str_repeat(' ', $indent);
        $pad1 = str_repeat(' ', $indent + 2);
        $fields = [];
        foreach ($entries as $key => $child) {
            $fields[] = "{$pad1}{$key}: " . inferZod($child, $indent + 2) . ',';
        }
        return "z.object({\n" . implode("\n", $fields) . "\n{$pad0}})";
    }

    // Unreachable for valid JSON; deterministic safety net.
    return 'z.unknown()';
}

/**
 * Convert a JSON string into a `const NAME = <zod schema>;` declaration.
 *
 * @return array{ok: bool, code: string, error: ?string}
 */
function jsonToZod(string $jsonString, ?string $rootName = null): array {
    // assoc=false keeps {} (stdClass) and [] (array) distinct, see inferZod.
    $value = json_decode($jsonString, false);

    if (json_last_error() !== JSON_ERROR_NONE) {
        // json_decode is fallible rather than exception-throwing: capture the
        // parse error message before any later call clears it.
        return ['ok' => false, 'code' => '', 'error' => json_last_error_msg()];
    }

    try {
        $name = sanitizeVarName($rootName ?? 'Root');
        return [
            'ok' => true,
            'code' => "const {$name} = " . inferZod($value, 2) . ';',
            'error' => null,
        ];
    } catch (\Throwable $e) {
        return ['ok' => false, 'code' => '', 'error' => $e->getMessage()];
    }
}

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 →