Runtime Data Validation in TypeScript with Zod
1. Overview
TypeScript’s types are erased after compilation. They don’t protect you from malformed JSON from APIs, user-submitted forms, or missing environment variables.
This guide shows you how to validate data at runtime using Zod 4. You’ll declare a schema once and infer types from it. You’ll also learn how to handle errors cleanly, write tests, and avoid some common pitfalls.
2. Why TypeScript Type Isn’t Enough at Runtime
Consider this familiar code:
interface Customer {
id: number;
email: string;
}
const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;
console.log(customer.id + 1);
console.log(customer.email.toLowerCase());
JSON.parse returns any. The as Customer assertion is just a promise to the compiler with no actual validation. The result at runtime:
421
TypeError: Cannot read properties of null (reading 'toLowerCase')
id is the string "42" so addition becomes string concatenation. email is null so the program crashes. The compiler says nothing. You need a real validation layer that runs at runtime. Zod does exactly that.
3. Installation
Zod has no other dependencies and runs in both Node.js and browsers:
npm install zod
For Zod to infer types correctly, enable strict mode in tsconfig.json:
{
"compilerOptions": {
"strict": true
}
}
Note: Without
"strict": true, optional fields may be inferred incorrectly. You’ll lose most of the type benefits.
4. Define Schema and Infer Type
A schema describes the shape of data you expect:
import { z } from "zod";
const CustomerSchema = z.object({
id: z.number().int().positive(),
email: z.email(),
fullName: z.string().min(2).max(100),
phone: z.string().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});
type Customer = z.infer<typeof CustomerSchema>;
Each part works as follows:
z.number().int().positive()requires a positive integer.z.email()checks if the string is a valid email format. In Zod 4, string formats like email, URL, and UUID are declared at the top level..optional()allows the field to be missing orundefined..default("FREE")auto-fills the value if the input doesn’t have atierfield.z.infergenerates a TypeScript type from the schema.
When you hover over Customer, you’ll see the equivalent type:
type Customer = {
id: number;
email: string;
fullName: string;
phone?: string | undefined;
tier: "FREE" | "PRO" | "ENTERPRISE";
}
The strength lies here: the schema is the single source of truth. When you add a field to the schema, the type updates automatically. You don’t need to maintain a parallel interface.
5. parse and safeParse
Zod has two main ways to run validation.
5.1. parse: throws an exception on error
const customer = CustomerSchema.parse({
id: 42,
email: "an.nguyen@example.com",
fullName: "Nguyễn Văn An",
});
console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }
The returned value has been validated and has type Customer. The tier field is filled with its default. If the data is invalid, parse throws a ZodError.
5.2. safeParse: returns a result without throwing
const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });
if (!result.success) {
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]
result is a discriminated union. After you check result.success, TypeScript knows which branch has data and which has error. You’ll also notice that Zod doesn’t stop at the first error—it collects all errors. This is very handy when displaying form errors.
The rule for choosing is simple:
- Use
safeParseat system boundaries: request bodies, forms, anywhere errors are normal. - Use
parsewhen invalid data means a bug or broken configuration and the app should stop immediately.
6. Custom Error Messages and Error Formatting
Zod’s default messages are in English and fairly technical. You can pass an error parameter to replace them:
const SignUpSchema = z.object({
email: z.email({ message: "Email is invalid" }),
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
});
const result = SignUpSchema.safeParse({ email: "abc", password: "123" });
if (!result.success) {
console.log(z.flattenError(result.error));
}
{
formErrors: [],
fieldErrors: {
email: [ 'Email is invalid' ],
password: [ 'Password must be at least 8 characters' ]
}
}
z.flattenError groups errors by field name. This format is good for returning to the frontend. When you need something for human readers, use z.prettifyError(result.error) to get a multi-line readable string.
7. Coerce, Transform, and Refine
Real data rarely matches a schema perfectly. Zod has three tools for handling these situations.
7.1. z.coerce: coerce input types
Query strings and environment variables are always strings. z.coerce converts them before validation:
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().max(100).default(20),
});
console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }
Note:
z.coerce.number()callsNumber(input). So an empty string""becomes0and passes type checking. Always add constraints like.min(1)to catch this.
7.2. transform: transform after validation
const OrderCodeSchema = z
.string()
.trim()
.regex(/^ord-\d{6}$/i, { message: "Order code format is invalid" })
.transform((code) => code.toUpperCase());
console.log(OrderCodeSchema.parse(" ord-000123 "));
ORD-000123
With transform, input and output types can differ. Use z.input<typeof S> and z.output<typeof S> to get each separately. z.infer is equivalent to z.output.
7.3. refine: arbitrary business rules
Some constraints involve multiple fields at once. Use refine at the object level:
const BookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
})
.refine((b) => b.checkOut > b.checkIn, {
message: "Check-out date must be after check-in date",
path: ["checkOut"],
});
const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] Check-out date must be after check-in date
The path option attaches the error to the checkOut field instead of the whole object. This way, the frontend displays the error in the right place.
8. Real-World Examples
8.1. Validate order creation request
This example is a request handler function independent of any framework. You can use it with Express, Fastify, or Next.js:
import { z } from "zod";
const OrderItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(99),
});
export const CreateOrderSchema = z.object({
customerId: z.number().int().positive(),
items: z.array(OrderItemSchema).nonempty({ message: "Order must have at least one item" }),
note: z.string().max(500).optional(),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
export function handleCreateOrder(body: unknown) {
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return { status: 400, body: z.flattenError(result.error).fieldErrors };
}
const order: CreateOrderRequest = result.data;
return { status: 201, body: { itemCount: order.items.length } };
}
Notice the body parameter is unknown, not any. This forces TypeScript to require validation before use. After safeParse, result.data has full type information. By default, z.object also strips unknown fields, so clients can’t sneak in isAdmin: true.
8.2. Validate environment variables on startup
An application should stop immediately if configuration is wrong, not wait until the first request:
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
});
export const env = EnvSchema.parse(process.env);
Suppose DATABASE_URL is missing. The application throws a ZodError immediately when the module is imported, with a message clearly indicating DATABASE_URL is missing. Everywhere else, env.PORT is type number, not string | undefined.
9. Writing Tests with Vitest
Schemas contain business logic and deserve tests. First, install Vitest:
npm install -D vitest
Then write tests for CreateOrderSchema:
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";
describe("CreateOrderSchema", () => {
const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };
it("accepts valid order and strips unknown fields", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [validItem],
isAdmin: true,
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ customerId: 7, items: [validItem] });
});
it("rejects order with no items", () => {
const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });
expect(result.success).toBe(false);
expect(z.flattenError(result.error!).fieldErrors.items).toContain(
"Order must have at least one item",
);
});
it("rejects quantity exceeding limit", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [{ ...validItem, quantity: 100 }],
});
expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
});
});
Run npx vitest run and all three tests pass. The third test shows how the error path drills down into array elements. This is exactly what the frontend needs to highlight the correct input field.
10. Common Mistakes and When Not to Use
Casting with as after you already have a schema. Writing body as CreateOrderRequest breaks the entire validation mechanism. Always get data from result.data.
Using async refine with parse. For example, checking if an email exists in the database. With async refine, you must call parseAsync or safeParseAsync. Otherwise Zod will throw an error.
Expecting unknown fields to be rejected. z.object silently strips unknown fields by default. If you want an error when extra fields are present—for example, to catch typos in a config file—use z.strictObject(...) instead.
Validating repeatedly inside the application. Only validate at system boundaries: requests, responses from third-party APIs, message queues, config files. Once data has been checked, trust its type. Parsing again in every function just wastes CPU.
Note: Don’t use Zod in hot loops processing hundreds of thousands of objects per second. Validation has real overhead. In those cases, validate once at the entry point or pick a compiler that generates code from schemas.
Declaring schemas inside functions. Creating a schema each time a function is called adds unnecessary initialization overhead. Declare schemas at module level and reuse them.
11. Conclusion
Zod bridges the gap between types at compile time and actual data at runtime. You declare a schema once, infer types using z.infer, then use safeParse at system boundaries to get clean data or a structured error list.
With coerce, transform, and refine, you can handle most real-world situations: query strings, data normalization, and multi-field business rules. Validate early, validate at the edges, and let TypeScript handle the rest.