Zod Part 3: Zod in a Real App — Types, Enums, and the Full Flow
By now you know how to define schemas, validate data, write custom rules, handle nested objects, and compose schemas from smaller pieces. That's the toolkit.
In this final post, we put it all together. You'll see how Zod connects to TypeScript so you never write the same type twice, how to handle values your API expects but your user never sees, and what the complete flow looks like — from a user filling in a form all the way to using the response data safely in your app.
📝 Note: This post was written with the assistance of AI. The content reflects my personal learning and understanding and should not be taken as professional advice. Please do your own research and due diligence before acting on anything written here.
🔗 z.infer — Getting TypeScript Types for Free
If you're using TypeScript, you've probably written something like this separately from your Zod schema:
// Your Zod schema
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(18),
});
// Your TypeScript type — describing the exact same thing
type User = {
name: string;
email: string;
age: number;
};
That's the same information written twice. If you add a field to the schema and forget to update the type — or vice versa — TypeScript will silently work with stale information, and bugs sneak in.
z.infer eliminates this entirely. It looks at your schema and generates the TypeScript type automatically:
import { z } from "zod";
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(18),
});
// Ask Zod to generate the type from the schema
type User = z.infer<typeof userSchema>;
// → { name: string; email: string; age: number }
// Use it anywhere TypeScript expects a type
function greetUser(user: User) {
console.log(`Hello, ${user.name}!`);
}
Now there's one source of truth — the schema. Update the schema, and the type updates automatically everywhere it's used.
🆚 But Wait — Does z.infer Give Me a type or an interface?
It gives you a type. For most everyday use, type and interface are interchangeable — both describe the shape of an object. But if you specifically need an interface (for example, because you want to extend it elsewhere), you can get one in a single line:
// Inline — no intermediate type needed
interface User extends z.infer<typeof userSchema> {}
// Now you can extend it further
interface AdminUser extends z.infer<typeof userSchema> {
role: "admin"; // add fields on top
}
The empty {} body is intentional — it means "I'm not adding anything new, I just want an interface that mirrors the schema." Add fields inside whenever you need to extend it.
type | interface | |
|---|---|---|
Works with z.infer directly | ✅ | Needs one extra line |
| Can be extended | ✅ (via intersections) | ✅ with extends |
| Most common in Zod codebases | ✅ | When explicitly needed |
Most developers stick with type from z.infer — it's less ceremony and works for the vast majority of cases.
🏷️ Enums — Values Your API Expects, Defined Once
Sometimes your API expects a specific set of values for a field — like a role that must be "user", "admin", or "moderator". You want TypeScript to enforce this, and you want Zod to validate it. z.enum() handles both:
// Define the allowed values — single source of truth
const UserRoleEnum = z.enum(["user", "admin", "moderator"]);
// Extract the TypeScript type from it
type UserRole = z.infer<typeof UserRoleEnum>;
// → "user" | "admin" | "moderator"
// Use it in your schema
const signupPayloadSchema = z.object({
name: z.string(),
email: z.string().email(),
password: z.string().min(8),
role: UserRoleEnum.default("user"), // always "user" unless overridden
});
If anything other than "user", "admin", or "moderator" is passed in for role, Zod catches it immediately.
.default("user") means if you don't pass role at all, Zod fills it in automatically — which is exactly what you want for a field the user never touches.
Enums in Dropdowns — The Label Map Pattern
Here's a real problem: your API expects "user" but your dropdown should show "User". These are different strings, and you don't want to scatter that translation logic all over your codebase.
The cleanest solution is a label map — a single object that lives next to your enum and translates backend values into display-friendly labels:
// 1. The enum — what the API accepts
const UserRoleEnum = z.enum(["user", "admin", "moderator"]);
type UserRole = z.infer<typeof UserRoleEnum>;
// 2. The label map — what the user sees in the UI
const UserRoleLabels: Record<UserRole, string> = {
user: "User",
admin: "Administrator",
moderator: "Moderator",
};
// 3. Build dropdown options — derived from both, never hardcoded twice
const dropdownOptions = UserRoleEnum.options.map((role) => ({
value: role, // sent to API → "user"
label: UserRoleLabels[role], // shown in UI → "User"
}));
// dropdownOptions:
// [
// { value: "user", label: "User" },
// { value: "admin", label: "Administrator" },
// { value: "moderator", label: "Moderator" },
// ]
UserRoleEnum.options gives you the enum values as a plain array at runtime — something TypeScript's native enum keyword can't do. Everything flows from one source. Add a new role to the enum, add one line to the label map, and the dropdown updates automatically.
🔄 The Full Flow — Form to Response
Here's everything working together — a complete signup flow with Zod sitting at every gate:
import { z } from "zod";
// ── Schemas ──────────────────────────────────────────────
const UserRoleEnum = z.enum(["user", "admin", "moderator"]);
// What the user fills in
const signupFormSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
email: z.string().email({ message: "Invalid email" }),
password: z.string().min(8).superRefine((val, ctx) => {
if (!/\d/.test(val))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Must contain a number" });
if (!/[A-Z]/.test(val))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Must contain an uppercase letter" });
}),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: "Passwords don't match", path: ["confirmPassword"] }
);
// What gets sent to the API — confirmPassword dropped, role added silently
const signupPayloadSchema = signupFormSchema
.omit({ confirmPassword: true })
.extend({ role: UserRoleEnum.default("user") });
// What the API sends back
const signupResponseSchema = z.object({
userId: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string(),
});
// ── Types ────────────────────────────────────────────────
type SignupForm = z.infer<typeof signupFormSchema>;
type SignupPayload = z.infer<typeof signupPayloadSchema>;
type SignupResponse = z.infer<typeof signupResponseSchema>;
// ── Step 1: Collect form data ────────────────────────────
const rawFormData = {
name: "Priya",
email: "priya@example.com",
password: "Hello123",
confirmPassword: "Hello123",
};
// ── Step 2: Validate form data ───────────────────────────
const formResult = signupFormSchema.safeParse(rawFormData);
if (!formResult.success) {
// Show errors to the user — stop here
formResult.error.errors.forEach((err) => {
console.log(`Field: ${err.path[0]} | Error: ${err.message}`);
});
throw new Error("Form validation failed");
}
// ── Step 3: Build the payload ────────────────────────────
const { confirmPassword, ...rest } = formResult.data;
// Zod fills in role: "user" automatically via .default()
const payload = signupPayloadSchema.parse({ ...rest });
// ── Step 4: Send to API ──────────────────────────────────
async function signup(payload: SignupPayload): Promise<SignupResponse> {
const response = await fetch("https://your-api.com/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), // object → JSON string
});
if (!response.ok) throw new Error("Request failed");
const json = await response.json();
// ── Step 5: Validate the response ───────────────────────
const responseResult = signupResponseSchema.safeParse(json);
if (!responseResult.success) {
console.log("Unexpected response:", responseResult.error.errors);
throw new Error("Invalid response shape");
}
return responseResult.data; // clean, typed, validated
}
// ── Step 6: Use the data ─────────────────────────────────
const user = await signup(payload);
// Fully typed — TypeScript knows exactly what fields exist
console.log(`Welcome, ${user.name}!`);
console.log(`Your ID: ${user.userId}`);
console.log(`Joined: ${user.createdAt}`);
Where Zod showed up and why
| Step | What Zod did |
|---|---|
| Form validation | Caught bad input before anything else happened |
| Password rules | superRefine returned multiple specific errors at once |
| Passwords match | .refine() compared two fields and pinned the error correctly |
| Payload construction | .omit() dropped confirmPassword, .extend() added role silently |
| Enum enforcement | z.enum() ensured only valid role values are ever sent |
| Response validation | Caught unexpected API response shapes before they reached your UI |
| Types everywhere | z.infer gave TypeScript types for form, payload, and response — all from schemas you already wrote |
🎯 What You Can Do Now
- Use
z.inferto generate TypeScript types directly from your schemas — no duplication - Create an
interfacefrom a Zod schema with a singleextends z.infer<...>line - Define enums with
z.enum()for values your API expects and TypeScript enforces - Build a label map to translate enum values into user-friendly display labels
- Wire up the complete flow: collect form data → validate → build payload → send → validate response → use data safely
The way you see a form changes once you've used Zod end to end. It's no longer just a bunch of inputs — it's a pipeline with Zod standing guard at the entry and exit points, making sure nothing unexpected gets in or out.

