Disclosure: I built the converter used in this post. The Zod code below is useful without it.
TypeScript types disappear at runtime. const user: User = await res.json() checks nothing. Zod validates data where it enters your app, so a first-draft schema from a sample response saves typing.
Here is a sample response:
{
"id": 4182,
"email": "ada@example.com",
"createdAt": "2026-03-14T09:30:00Z",
"profile": { "displayName": "Ada", "avatarUrl": null },
"roles": ["admin", "editor"],
"orders": [
{ "orderId": "ord_1", "total": 49.9, "coupon": "SPRING" },
{ "orderId": "ord_2", "total": 12 }
]
}
I ran it through the converter. This is its output, unedited:
import { z } from "zod";
const RootSchemaProfileSchema = z.object({
displayName: z.string(),
avatarUrl: z.null(),
});
const RootSchemaOrdersItemSchema = z.object({
orderId: z.string(),
total: z.number(),
coupon: z.string(),
});
const RootSchemaOrdersItemSchema2 = z.object({
orderId: z.string(),
total: z.number(),
});
const RootSchema = z.object({
id: z.number(),
email: z.string(),
createdAt: z.string(),
profile: RootSchemaProfileSchema,
roles: z.array(z.string()),
orders: z.array(z.union([RootSchemaOrdersItemSchema, RootSchemaOrdersItemSchema2])),
});
export { RootSchema };
It’s a starting point, not a contract. What one sample can’t tell you:
- Two order schemas. The two orders have different keys, so you get a union. You probably want one OrderSchema with coupon optional. Merge them by hand.
- avatarUrl: z.null(). The sample had null, so that’s all it knows. A real API likely returns a string or null.
- email and createdAt are plain strings. A sample can’t prove a format. Add stricter checks when your API docs promise one.
- "12" stays a string. Don’t guess that numeric-looking strings are numbers.
- Empty arrays. [] becomes z.array(z.null()), which would reject real items.
After my edits
import { z } from "zod";
const ProfileSchema = z.object({
displayName: z.string(),
avatarUrl: z.string().nullable(), // from the API docs, not the sample
});
const OrderSchema = z.object({
orderId: z.string(),
total: z.number(),
coupon: z.string().optional(),
});
export const UserSchema = z.object({
id: z.number(),
email: z.string(),
createdAt: z.string(),
profile: ProfileSchema,
roles: z.array(z.string()),
orders: z.array(OrderSchema),
});
export type User = z.infer<typeof UserSchema>;
Use it where data enters your app:
async function loadUser(res: Response): Promise<User | null> {
const result = UserSchema.safeParse(await res.json());
if (!result.success) {
console.error(result.error.issues);
return null;
}
return result.data;
}
Check any generated schema against your API docs or OpenAPI spec before relying on it.
The converter is here. Pasted JSON is converted in your browser and isn’t uploaded. The site uses basic analytics for page views and button clicks, never your input.
Top comments (0)