
Aishwarya B RProduction is down. You're staring at Cannot read property 'email' of undefined for the 47th time...
Production is down. You're staring at Cannot read property 'email' of undefined for the 47th time this quarter. The user object was supposed to have an email. The API docs said it would have an email. Your colleague who left six months ago definitely said it would have an email.
But it doesn't. And now you're debugging a codebase where any appears 847 times and every function accepts "whatever, man" as a valid argument.
Welcome to JavaScript Hell. TypeScript is the exit door—if you use it right.
TypeScript is a static type system that catches bugs at compile time. It's JavaScript with guardrails that:
// user object, has stuff comments)Think of it as spell-check for your logic. Red squiggles before you hit send.
❌ A runtime safety net — Types disappear after compilation. TypeScript won't save you from bad API responses at runtime.
❌ A performance booster — It compiles to JavaScript. Same speed, same output.
❌ An excuse to over-engineer — type NestedGenericFactoryBuilderStrategy<T extends AbstractBase<K>, K> is not a flex.
❌ Just "JavaScript with types" — Used properly, it changes how you design code, not just how you annotate it.
You don't need a 40-hour course. You need:
tsconfig.json before you touch anything. Half the time "TypeScript isn't working" actually means "I don't understand what strict: true does." Save yourself the existential crisis.Pick a small project—a CLI tool, a utility library, an API client—and type it strictly. You'll learn more from one strict: true project than ten tutorials.
The rest of this post? It's the thinking part—how to evolve from "TypeScript that compiles" to "TypeScript that protects."
any to Type Safety
Let's fix a real function: fetching and displaying a user.
async function getUser(id: any): Promise<any> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
const user = await getUser(123);
console.log(user.emial); // typo? TypeScript: LGTM 👍
Problems: Zero type safety. any in, any out.
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json(); // ⚠️ lying to TypeScript
}
const user = await getUser(123);
console.log(user.emial); // ✅ Error: Property 'emial' does not exist
What it does: Catches typos. Autocomplete works. Your IDE is useful now.
Problems: You're promising the API returns a User, but APIs lie. No runtime validation.
interface User {
id: number;
name: string;
email?: string; // might not exist
avatar?: string | null; // might be null
}
async function getUser(id: number): Promise<User | null> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return null;
return res.json();
}
const user = await getUser(123);
if (user) {
console.log(user.email?.toUpperCase()); // safe chaining
}
What it does: Models reality—things can be missing or null. Forces you to handle edge cases.
Problems: Still trusting res.json() blindly. No validation that the response actually matches User.
interface User {
id: number;
name: string;
email?: string;
}
type ApiResult<T> =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: T };
async function getUser(id: number): Promise<ApiResult<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return { status: "error", message: `HTTP ${res.status}` };
}
const data = await res.json();
return { status: "success", data };
} catch (e) {
return { status: "error", message: "Network failed" };
}
}
const result = await getUser(123);
switch (result.status) {
case "loading":
showSpinner();
break;
case "error":
showError(result.message); // TS knows `message` exists here
break;
case "success":
showUser(result.data); // TS knows `data` is User here
break;
}
What it does: Models all possible states. TypeScript narrows types in each branch. Impossible to access data when status is "error".
Problems: Still no runtime guarantee that data matches User. If the API changes, you'll find out in production.
interface User {
id: number;
name: string;
email?: string;
}
// Type guard: validates at runtime, narrows at compile time
function isUser(obj: unknown): obj is User {
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
typeof (obj as User).id === "number" &&
"name" in obj &&
typeof (obj as User).name === "string"
);
}
type ApiResult<T> =
| { status: "error"; message: string }
| { status: "success"; data: T };
async function getUser(id: number): Promise<ApiResult<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return { status: "error", message: `HTTP ${res.status}` };
}
const json: unknown = await res.json(); // don't trust it
if (!isUser(json)) {
return { status: "error", message: "Invalid user data" };
}
return { status: "success", data: json }; // TS knows it's User
} catch {
return { status: "error", message: "Network failed" };
}
}
What it does:
unknown (honest)User after validation passesProduction-ready. For complex schemas, use libraries like Zod or Valibot instead of hand-written guards.
| Framework | Conventional Choice | Why |
|---|---|---|
| Next.js / React | Zod, Valibot | Functional style, tree-shakeable, no decorators needed |
| NestJS | class-validator + class-transformer | Decorator-based, integrates with NestJS pipes/DTOs natively |
TL;DR:
instanceof
| Question | 🚩 Red Flag | ✅ Benefit |
|---|---|---|
| "What type is this?" |
any everywhere |
Explicit interfaces |
| "Can this be null?" | Unchecked .property access |
Optional chaining + null checks |
| "What can this function return?" | Promise<any> |
Union types for all states |
| "Is this API response safe?" | Casting as User
|
Runtime validation + type guards |
| "What properties does this have?" |
console.log to find out |
Autocomplete knows |
| "Will this refactor break things?" | "Deploy and pray" | Compiler errors before merge |
🚫 Over-typing everything — Don't create interfaces for objects used once. Inline types work: function greet(user: { name: string }) {}
🚫 Complex generics for simple problems — If your type definition is longer than the function, reconsider.
🚫 Typing third-party API responses you don't control — Use runtime validation instead of lying with interfaces.
🚫 100% type coverage as a goal — Some any or unknown is fine at boundaries. Perfect is the enemy of shipped.
.cursorrules (or rules in cursor settings)
# TypeScript Rules
- Never use `any` — use `unknown` and narrow with type guards
- Treat all external data as `unknown`, validate with Zod
- Use discriminated unions for state (loading/error/success)
- Prefer Result pattern over thrown exceptions
- `strict: true` always
Write a TypeScript function to fetch users from `/api/users`.
Requirements:
- Use Zod for response validation
- Return a discriminated union: `{ status: "error"; message: string } | { status: "success"; data: User[] }`
- Handle network errors and validation failures separately
- Infer the User type from the Zod schema
TypeScript isn't about adding colons and angle brackets to your code. It's about making illegal states unrepresentable.
Start here: Enable strict mode. Model your API responses honestly. Use unions for state. Validate at boundaries.
Stop writing types. Start designing with types.