The Backend Changed the API and My App Crashed: Why You Need Zod
"But TypeScript Said It Was Safe!"
It was 5 PM on a Friday. I was ready to leave. Suddenly, Slack pinged as "High Priority". "The user list page is blank. White screen." "What? It works fine on my local machine."
I checked the production logs.
Uncaught TypeError: Cannot read properties of undefined (reading 'map')
My code looked fine. TypeScript compiler was happy (green).
I dug deeper and found the cause: The backend developer had deployed a "hotfix".
They changed the API response. Instead of an empty array users: [] when no data exists, it now returned users: null.
My frontend code, trusting TypeScript's type inference (UserResponse), expected an array and tried to run .map().
When null arrived at runtime, the app exploded.
What Confused Me Initially? (The Betrayal of TypeScript)
I thought, "Isn't TypeScript supposed to prevent these type errors?"
I had defined interface UserResponse { users: User[] }. I trusted that contract.
But I realized: TypeScript only exists at Compile Time.
Once the code runs in the browser(Runtime), TypeScript is stripped away basically leaving naked JavaScript.
TypeScript has NO WAY of knowing what data actually arrives from the server. It just "trusts" your interface definition.
At the Boundaries of your application (API, DB, User Input), TypeScript provides zero protection. It is essentially blind.
The 'Aha!' Moment (Immigration Check Analogy)
I understood this by comparing it to an "Immigration Check at an Airport."
- TypeScript: "Your passport photo looks valid." (Document check - Compile Time)
- Runtime Data: The actual person getting off the plane. (Runtime)
- The Problem: The document says "Safe Tourist", but the actual person might be a "Terrorist" (Malformed Data). If you let them in just based on the paper, your airport (App) blows up.
- Zod: The Security Officer with an X-Ray scanner. "Open your bag. The document says you have fruit, but this is a bomb. Entry Denied (Error Thrown)."
We must assume "All external data is Tainted." We must verify it at the gate before letting it into our application logic.
The Fix: Adopting Zod
At first, I wrote defensive code like if (data && data.users && Array.isArray(data.users)).
But for 100 fields? That's messy and unmaintainable.
So I introduced Zod (Schema Validation Library).
Step 1: Define Schema (The Constitution)
Instead of TypeScript interfaces, define Zod schemas first.
import { z } from "zod";
// 1. Define Schema (Runtime Validation Logic)
const UserSchema = z.object({
id: z.number(),
name: z.string().min(2, "Name too short"),
email: z.string().email(),
// Backend often sends null? Handle it explicitly!
role: z.enum(["admin", "user"]).nullable(),
});
// 2. Infer Type (Compile Time Type)
// Zod generates the TypeScript type for you. No duplication!
type User = z.infer<typeof UserSchema>;
Now User type is for the compiler, and UserSchema is for the runtime. One Source of Truth.
Step 2: Validate Data (The Inspection)
When data arrives from API, don't use it immediately. Pass it through Zod.
/* Old Way (Dangerous) */
// const user = await response.json() as User; // 'as' is a lie
/* Zod Way (Safe) */
const json = await response.json();
try {
// parse: If data doesn't match schema, it throws an error immediately.
const user = UserSchema.parse(json);
console.log(user.name); // 100% Type-Safe Guaranteed here.
} catch (error) {
// Schema Mismatch! Instead of crashing, handle gracefully.
console.error("API Contract Violation:", error);
showToast("Received invalid data from server.");
}
Now, if the server sends a string for id instead of a number, Zod blocks it right at the door: "Halt! ID must be a number."
Your app doesn't crash with a undefined error deep in your UI tree. You get a controlled error state.
Step 3: safeParse (Quiet Handling)
If you dislike try-catch blocks, use safeParse.
const result = UserSchema.safeParse(json);
if (!result.success) {
// Contains detailed error info
console.log(result.error.format());
return null;
}
// result.data is the validated User data
return result.data;
Deep Dive: How Zod Saves DX (Developer Experience)
Using Zod had an unexpected benefit: "I can prove the Backend is lying."
Swagger docs often say a field is 'Required', but in reality, it comes as null.
Without Zod, I'd waste 3 hours debugging my code: "Why is this breaking?" With Zod, I know in 3 seconds.
ZodError: Expected string, received null at "address.city"
I just screenshot this log and send it to the backend dev. "The response doesn't match the docs." Accountability becomes clear, and debugging time drops to near zero.
Transform API (Data Cleanup)
You can validate AND transform data in one go.
const PriceSchema = z.string()
// "1,000" -> 1000 (number)
.transform((val) => parseInt(val.replace(/,/g, ""), 10));
const result = PriceSchema.parse("1,000"); // result is 1000
Perfect for converting Date strings to Date objects or cleaning up user input formats.
Application: Form Validation (feat. React Hook Form)
Zod isn't just for APIs. It's the king of Form Validation.
Combined with react-hook-form and zod-resolver, it's magic.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
username: z.string().min(2, "Too short"),
age: z.number().min(18, "Must be 18+"),
});
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema), // Use Zod schema as validation logic!
});
No more manual if (value.length < 2) checks in your submit handler. One schema handles everything.
Deep Dive Glossary
1. Runtime vs. Compile Time
Compile Time: When your code is being converted (transpiled) from TS to JS. Types exist here. Runtime: When your code is actually running in the user's browser. Types are gone. Zod brings type safety to Runtime.
2. Type Inference
The ability of the TypeScript compiler to deduce the type of a variable automatically. Zod's z.infer<typeof Schema> allows you to extract TypeScript types directly from your runtime validation logic, preventing code duplication.
3. Serialization / Deserialization
Serialization: Converting an object into a storable format (like JSON string). Deserialization: Converting that string back into an object. This is the danger zone where Zod shines, verifying the data integrity after deserialization.
4. Catch Block Scope
In TypeScript, the error object in a catch(error) block is of type unknown or any. You should use checking functions (Type Guards) like if (error instanceof ZodError) to handle validation errors differently from network errors.
One-Line Summary
TypeScript cannot protect you at runtime. You must 'frisk' all external data (API) using Zod at the entry point. Using 'as' type assertions is like blindly letting strangers into your home without checking ID.