DEV Community

Aye Nyein Chan Moe
Aye Nyein Chan Moe

Posted on

Generating a Zod schema from an API response sample, and what a sample can’t tell you

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 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)