Skip to main content

From TypeScript to Zod: Validate the Data Your Types Can't See

TypeScript types vanish at runtime. Turn your interfaces into Zod schemas to catch malformed API responses and form input before they crash your app.

Published By Li Lei
#typescript #zod #validation #runtime-validation #api

From TypeScript to Zod: Validate the Data Your Types Can't See

There's a quiet lie in almost every TypeScript codebase I've worked on. It looks like this:

const data = await res.json() as ApiResponse;

That as ApiResponse tells the compiler "trust me, this is the right shape." The compiler does trust you. The server, the user, and the third-party webhook do not. And the moment your code runs, the type is gone.

This post is about closing that gap with Zod, and about a small habit that makes it nearly free: generating the Zod schema straight from the TypeScript interface you already wrote.

Why your types disappear the moment the code runs

TypeScript is a compile-time tool. Types are checked while you write and build, then erased. The JavaScript that actually executes in Node or the browser has no idea your variable was supposed to be a User with a string email and a number age. There is no typeof for your interfaces, no field list to check against, nothing.

So when an API returns { "age": "42" } instead of { "age": 42 }, TypeScript can't help — it already did its job and left the building. Your data.age + 1 quietly becomes "421", and three function calls later something throws an error that points nowhere near the real cause.

Zod fixes this by validating the actual runtime value, not the compile-time promise. A Zod schema is a real JavaScript object that knows how to inspect data and reject what doesn't match. When you call UserSchema.parse(raw), it walks the data, checks every field, and either hands you a correctly typed value or throws a precise ZodError listing exactly which field was wrong and why. The check happens where the bad data enters, not three stack frames deep.

One worked example: interface in, schema out

Here's the kind of conversion the TypeScript to Zod Schema converter does. You paste this interface:

interface CreateUserDto {
  id: string;
  email: string;
  age?: number;
  role: 'admin' | 'editor' | 'viewer';
  tags: string[];
  profile: {
    bio: string;
    website: string | null;
  };
}

And you get back this schema:

import { z } from "zod";

export const CreateUserDtoSchema = z.object({
  id: z.string(),
  email: z.string(),
  age: z.number().optional(),
  role: z.enum(["admin", "editor", "viewer"]),
  tags: z.array(z.string()),
  profile: z.object({
    bio: z.string(),
    website: z.string().nullable(),
  }),
}).strict();

export type CreateUserDto = z.infer<typeof CreateUserDtoSchema>;

Notice the translation choices that match how Zod actually wants to be written. The ? on age becomes .optional(). The string-literal union 'admin' | 'editor' | 'viewer' collapses into z.enum([...]) — one clean line instead of three z.literal() calls. The string | null on website becomes .nullable(), Zod's short form. The nested profile object renders as an inline z.object(). And the whole thing closes with .strict(), which throws if the server sends a key you never declared instead of silently dropping it.

That last detail matters more than it looks. A plain z.object() strips unknown keys without telling you, so contract drift slips through. At an API boundary you usually want the opposite: loud failure when the shape changes.

Where the schema earns its keep: API and form boundaries

The two places this pays off most are the edges where untyped data crosses into your typed code.

API responses. Replace the cast with a parse:

const data = CreateUserDtoSchema.parse(await res.json());

Now a malformed response throws a clear, named error at the fetch boundary. You can also use .safeParse() to get a discriminated { success: true, data } | { success: false, error } back instead of a throw, which feeds straight into an error UI.

Forms. The same schema you use to validate the API request can drive your form. Hand it to react-hook-form through zodResolver(CreateUserDtoSchema), and your form fields validate against the exact same contract as your backend. One source of truth, checked on both sides. When you later add CreateUserDtoSchema.parse(req.body) at the endpoint, the front end and back end can't drift apart, because they're literally the same schema.

Config files. Wrap a flags.json load in z.array(FlagSchema).parse(JSON.parse(...)) at boot, and a malformed entry crashes the app at startup — not in production three weeks later when that one bad flag finally gets read.

How I actually use this day to day

I'll be honest about my own workflow, because the "right way" of hand-writing every schema never survived contact with a real deadline. When I'm wiring up a new endpoint, I start with the TypeScript, because that's how I think about shapes — interfaces, optionals, unions. I write the DTO, get my editor autocomplete working, build the feature.

Then, right before I trust any external data, I paste the interface into the converter, copy the schema, and drop a single parse() call at the boundary. It takes about thirty seconds, and it's the difference between a bug that throws ZodError: Expected number, received string at "age" and a bug that surfaces as NaN in a chart two screens away. I've chased the second kind of bug for an hour more times than I want to admit. The schema turns a mystery into a one-line error message.

The other place it saves me is keeping a hand-written schema in sync after a refactor. I rename a field on the interface, the old OrderSchema goes stale, and instead of diffing by eye I regenerate and apply the difference. Faster, and I stop missing the one field that only shows up in production.

Know the edges: what Zod genuinely can't model

Being honest about the limits keeps you out of trouble. Some TypeScript constructs exist only at compile time and have no runtime shape to validate: conditional types (T extends X ? A : B), mapped types ({ [K in keyof T]: ... }), template literal types, infer clauses, and keyof. These are shape manipulators, not data, so there's nothing for Zod to check. A good converter flags these as warnings above the output and emits z.unknown() rather than guessing — paste-and-fix, not silent loss.

A couple of practical gotchas worth remembering:

  • z.enum is string-only. A union like 1 | 2 | 3 can't become z.enum([1, 2, 3]) (it won't type-check). It renders as z.union([z.literal(1), z.literal(2), z.literal(3)]), and you can tighten it further with .refine().
  • Imports don't resolve. Only declarations in the text you paste get parsed. A reference to a type imported from another file falls back to z.unknown() — paste the related declarations together, or hand-edit the import in.

If you're going the other direction — inferring types from a raw JSON payload first — pair this with the JSON to TypeScript interface generator: infer the interface from a sample response, then convert that interface to a Zod schema. You end up with static types and a runtime validator derived from the same source, both regenerable when the payload changes.

The one-line habit worth keeping

The whole point is small enough to state in a sentence: TypeScript checks your code, Zod checks your data, and the data is where the surprises live. Generating the schema from the interface you already wrote removes the only real excuse for skipping runtime validation — that it's tedious to write by hand. Paste the interface, copy the z.object(), drop one parse() at the boundary. The next malformed response tells you exactly what broke.


Made by Toolora · Updated 2026-06-13