Skip to content

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

/**
 * json-to-zod - JavaScript 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 surfaced as
 * { ok: false, error }.
 *
 * Ported from src/lib/jsonToZod.ts (CosmoDev).
 * Display source - part of CosmoDev's polyglot tool pages (dev.cosmolabs.org).
 */

/**
 * A "plain object" is a non-null, non-array object - i.e. exactly what
 * JSON.parse produces for `{...}`. We exclude arrays explicitly because
 * typeof [] === 'object'.
 */
function isPlainObject(v) {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/**
 * 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" if nothing usable remains
 */
function sanitizeVarName(name) {
  const cleaned = name
    .replace(/[^A-Za-z0-9_$]/g, '')
    .replace(/^[0-9]+/, (m) => '_'.repeat(m.length));
  return cleaned || 'schema';
}

/**
 * Indent every non-empty line of `s` by `depth` spaces. Empty lines are left
 * untouched so blank separators don't acquire trailing whitespace.
 */
function pad(s, depth) {
  const p = ' '.repeat(depth);
  return s
    .split('\n')
    .map((l) => (l.length ? p + l : l))
    .join('\n');
}

/**
 * Infer a Zod schema string for `value` at the given indentation depth.
 * Exported so callers can convert an already-parsed value directly.
 */
export function inferZod(value, indent = 2) {
  if (value === null) return 'z.null()';
  switch (typeof value) {
    case 'string':
      return 'z.string()';
    case 'number':
      return 'z.number()';
    case 'boolean':
      return 'z.boolean()';
  }

  if (Array.isArray(value)) {
    // Empty array → unknown element type (Zod has nothing to infer from).
    if (value.length === 0) return 'z.array(z.unknown())';

    // Recurse at a deeper indent so nested shapes line up.
    const types = value.map((e) => inferZod(e, indent + 2));
    const distinct = [...new Set(types)];

    // Single shared element type → z.array(T). Multiple → z.union([...]).
    // Note: the union intentionally renders the *full* `types` list (with
    // duplicates), matching the TypeScript reference exactly.
    let inner;
    if (distinct.length === 1) {
      inner = distinct[0];
    } else {
      inner = `z.union([\n${pad(types.join(',\n'), indent + 2)}\n${pad('', indent)}])`;
    }
    return `z.array(${inner})`;
  }

  if (isPlainObject(value)) {
    const pad0 = ' '.repeat(indent);
    const pad1 = ' '.repeat(indent + 2);
    const entries = Object.entries(value);
    if (entries.length === 0) return 'z.object({})';

    const fields = entries.map(
      ([k, v]) => `${pad1}${k}: ${inferZod(v, indent + 2)},`,
    );
    return `z.object({\n${fields.join('\n')}\n${pad0}})`;
  }

  // Safety net: for valid JSON this is unreachable (JSON has no undefined /
  // bigint / function), but we keep a deterministic fallback.
  return 'z.unknown()';
}

/**
 * Convert a JSON string into a `const NAME = <zod schema>;` declaration.
 *
 * @param {string} jsonString - Raw JSON input.
 * @param {{ rootName?: string }} [opts] - Optional output variable name.
 * @returns {{ ok: boolean, code: string, error: string|null }}
 */
export function jsonToZod(jsonString, opts = {}) {
  let value;
  try {
    value = JSON.parse(jsonString);
  } catch (e) {
    return { ok: false, code: '', error: e?.message ?? String(e) };
  }
  try {
    const rootName = sanitizeVarName(opts.rootName || 'Root');
    return { ok: true, code: `const ${rootName} = ${inferZod(value, 2)};`, error: null };
  } catch (e) {
    return { ok: false, code: '', error: e?.message ?? String(e) };
  }
}

export default jsonToZod;

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 →