
The Backend Changed the API and My App Crashed: Why You Need Zod
Trusted TypeScript but got crashed by runtime data? Learn why you need 'Runtime Validation' and how to use Zod to secure your data layer.

Trusted TypeScript but got crashed by runtime data? Learn why you need 'Runtime Validation' and how to use Zod to secure your data layer.
Stop using 'any'. How to build reusable, type-safe components using Generics. Advanced patterns with extends, keyof, and infer.

Fixing the crash caused by props coming through as undefined from the parent component.

Troubleshooting absolute path import configuration issues in TypeScript/JavaScript projects. Exploring the 'Map vs Taxi Driver' analogy, CommonJS vs ESM history, and Monorepo setup.

Managing API state with `isLoading`, `isError`, `data`? Learn how Discriminated Unions prevent 'impossible states' and simplify your logic.

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.
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.
I understood this by comparing it to an "Immigration Check at an Airport."
We must assume "All external data is Tainted." We must verify it at the gate before letting it into our application logic.
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).
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.
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.
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;
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.
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.
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.
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.
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.
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.
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.
Zod at the entry point. Using 'as' type assertions is like blindly letting strangers into your home without checking ID.