I spent years writing TypeScript and still had runtime errors caused by bad data. Types are great at compile time but they completely disappear at runtime. Your API could return anything, a form could submit garbage, and TypeScript wouldn’t save you.
So I wrote manual validation everywhere. if (!data.email) checks, typeof guards, nested conditions that made my code twice as long as it needed to be. It worked but it was tedious and I kept missing edge cases.
Then I started using Zod and honestly it changed how I write TypeScript. Not in a dramatic way, just in a “why was I doing it the hard way” kind of way.
What Zod actually does
Zod lets you define a schema and then validate data against it at runtime. If the data matches, you get a fully typed object. If it doesn’t, you get detailed error messages.
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(18),
});
const result = UserSchema.safeParse({
name: "Baransel",
email: "hello@baransel.dev",
age: 25,
});
if (result.success) {
console.log(result.data); // fully typed as { name: string, email: string, age: number }
} else {
console.log(result.error.flatten());
}
The key thing here is that result.data is automatically typed. You don’t need to define a separate TypeScript interface. The schema is the type.
Inferring types from schemas
This is where it gets really good. Instead of writing a schema AND a type, you write the schema once and infer the type from it:
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user", "editor"]),
});
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; role: "admin" | "user" | "editor" }
One source of truth. Change the schema and the type updates automatically. I used to have schemas and interfaces that would drift apart over time. Now that’s impossible.
Validating API responses
This is where Zod saved me the most. I used to just cast API responses to a type and hope for the best:
// don't do this
const res = await fetch("/api/users");
const users = (await res.json()) as User[];
That as User[] is a lie. You’re telling TypeScript “trust me, this is the right shape.” But if the API changes or returns an error, you get weird runtime bugs that are hard to trace.
With Zod:
const UsersResponse = z.array(
z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
);
const res = await fetch("/api/users");
const json = await res.json();
const users = UsersResponse.parse(json);
If the API returns something unexpected, Zod throws immediately with a clear error telling you exactly which field is wrong. No more debugging for 30 minutes because user.name is undefined somewhere deep in your component tree.
Form validation
I use Zod for every form now. Define the schema once and use it for both client-side validation and server-side validation.
const ContactFormSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
// client-side
function handleSubmit(formData: FormData) {
const result = ContactFormSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
});
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
// { name: ["Name is required"], email: ["Invalid email address"] }
return errors;
}
// result.data is clean and typed
submitToApi(result.data);
}
The error messages are built into the schema. You don’t need a separate validation library. And if you’re using React Hook Form, there’s @hookform/resolvers that plugs Zod in directly:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm({
resolver: zodResolver(ContactFormSchema),
});
That’s it. React Hook Form now uses your Zod schema for all validation.
Transforming data
Zod doesn’t just validate, it can also transform data as it parses. This is incredibly useful for cleaning up API inputs.
const SearchParams = z.object({
page: z.string().transform(Number).pipe(z.number().min(1).default(1)),
query: z.string().trim().toLowerCase(),
limit: z.string().transform(Number).pipe(z.number().max(100)).default(20),
});
const params = SearchParams.parse({
page: "3",
query: " TypeScript ",
limit: "50",
});
// { page: 3, query: "typescript", limit: 50 }
Query parameters always come in as strings. Zod converts them to numbers, trims whitespace, normalizes casing, all in one step. Without Zod I’d have a bunch of parseInt() calls and .trim().toLowerCase() scattered everywhere.
Discriminated unions
This one is underrated. When you have different response shapes based on a type field, Zod handles it cleanly:
const ApiResponse = z.discriminatedUnion("status", [
z.object({
status: z.literal("success"),
data: z.object({ id: z.string(), name: z.string() }),
}),
z.object({
status: z.literal("error"),
message: z.string(),
code: z.number(),
}),
]);
type ApiResponse = z.infer<typeof ApiResponse>;
const response = ApiResponse.parse(json);
if (response.status === "success") {
console.log(response.data.name); // TypeScript knows this exists
} else {
console.log(response.message); // TypeScript knows this exists too
}
Type narrowing works perfectly because Zod and TypeScript both understand the discriminated union. No manual type guards needed.
Environment variables
I validate environment variables with Zod at startup now. This way my app crashes immediately if a variable is missing, instead of failing randomly later when some function tries to use it.
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
NEXT_PUBLIC_API_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production", "test"]),
});
export const env = EnvSchema.parse(process.env);
Now env.DATABASE_URL is typed and guaranteed to exist. If you deploy without setting a variable, you get a clear error at startup instead of a cryptic crash at 3am.
When not to use Zod
Zod isn’t the answer to everything. I don’t use it for:
- Internal function parameters where TypeScript is already enough
- Data that I created myself and know the shape of
- Hot paths where parsing thousands of objects per second (the overhead is small but it exists)
For anything that crosses a trust boundary though — API responses, form inputs, URL params, environment variables, webhook payloads — Zod is the first thing I reach for.
It just removes an entire category of bugs
Before Zod, I’d spend hours tracking down bugs caused by unexpected data shapes. A field that was supposed to be a string came back as null. An array was empty when I expected at least one item. A number was actually a string because it came from a query parameter.
Now those bugs don’t happen. The data is validated at the edge and everything after that point is guaranteed to be the right shape. My TypeScript types actually match reality.
That’s it. Install it, try it on one API call, and you’ll see why I’m never going back.