You have an API response. You want a Zod schema. So you paste the JSON into a JSON-to-Zod converter, copy the output, and ship it.
Here's the trap: a lot of those converters infer basic types only. Your email, your uuid, your url, your ISO timestamp — they all come out as z.string(). The schema compiles, the types look right, and your validator quietly accepts "not-an-email", "ftp://nope", and "2026-99-99" forever.
I wanted to see exactly how much gets lost, so I ran the same payload through two tools and diffed the output. Everything below is real, copy-pasteable output — nothing edited.
The input
A pretty ordinary user object:
{"id":"3f2a9c1e-5b7d-4e8a-9f1c-2d3e4f5a6b7c","email":"ada@example.com","website":"https://ada.dev","age":34,"rating":4.7,"created_at":"2026-03-04T10:15:30Z","is_active":true,"address":{"city":"Lyon","zip":"69001"},"tags":["early-adopter","beta"]}
Tool 1 — json-to-zod (npm)
const user = z.object({
id: z.string(),
email: z.string(),
website: z.string(),
age: z.number(),
rating: z.number(),
created_at: z.string(),
is_active: z.boolean(),
address: z.object({ city: z.string(), zip: z.string() }),
tags: z.array(z.string()),
});
Structurally correct. But every meaningful field is a bare z.string() / z.number(). This schema will happily validate email: "lol" and created_at: "yesterday".
Tool 2 — TypeMorph
import { z } from "zod";
export const userAddressSchema = z.object({
city: z.string(),
zip: z.string().regex(/^[A-Z0-9][A-Z0-9\s\-]{1,8}[A-Z0-9]$/i),
});
export type UserAddress = z.infer<typeof userAddressSchema>;
export const userSchema = z.object({
id: z.uuid(),
email: z.email(),
website: z.url(),
age: z.number().int().min(0).max(150),
rating: z.number().min(0).max(5),
created_at: z.iso.datetime(),
is_active: z.boolean(),
address: userAddressSchema,
tags: z.array(z.string()),
});
export type User = z.infer<typeof userSchema>;
The honest diff
| Field | Value | json-to-zod |
TypeMorph |
|---|---|---|---|
id |
a UUID | z.string() |
z.uuid() |
email |
an email | z.string() |
z.email() |
website |
a URL | z.string() |
z.url() |
created_at |
ISO datetime | z.string() |
z.iso.datetime() |
age |
34 |
z.number() |
z.number().int()… |
Two things worth being honest about:
-
The format detection is real and value-derived.
z.uuid(),z.email(),z.url(),z.iso.datetime()come from looking at the actual values — they're facts, not guesses. -
The numeric ranges and the zip regex are name-based guesses.
age→.min(0).max(150),rating→.min(0).max(5)are inferred from field names, not values, so they can be wrong (a rating could be 0–10). TypeMorph lets you turn that "smart" layer off and keep only the facts. I'd rather it be upfront about which is which than pretend a guess is a fact.
Why this matters
A Zod schema's whole job is to reject bad data. z.string() where you meant z.email() is a validator that passes the exact inputs you wrote it to catch. You get the feeling of validation with none of the coverage — which is worse than no schema, because you stop checking.
When json-to-zod is still fine
It's a tiny, zero-dependency function you can call inside your own code. If you just want a rough scaffold and you'll add .email() / .uuid() / enums by hand, it does the job. It's also basically unmaintained at this point (last publish was years ago), so don't expect Zod v4 output or fixes.
Try the comparison yourself
npx typemorph-cli zod your-response.json --root User
- CLI:
npm i -g typemorph-cli - Web (paste JSON, get 40+ formats): https://typemorph.dev
- Full honest comparison: https://typemorph.dev/alternatives/json-to-zod
I'm building TypeMorph in public — a JSON/OpenAPI → Zod/TypeScript/Go/Prisma converter that tries to detect what your data actually is instead of dumping everything to z.string(). Feedback (and "your inference got this wrong") very welcome.