2. Type Narrowing
Narrowing is how TypeScript figures out a more specific type inside a block. It is the skill that separates "I annotate variables" from "I use the type system".
1. typeof Guard
function format(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // narrowed to string
}
return value.toFixed(2); // narrowed to number
}Works for "string", "number", "boolean", "bigint", "symbol", "undefined", "object", "function".
The null Trap
function f(x: object | null) {
if (typeof x === "object") {
// x is still object | null — typeof null === "object"
}
}Check for null explicitly.
2. Truthiness Narrowing
function greet(name?: string) {
if (name) {
return `Hello ${name}`; // string
}
return "Hello stranger";
}The Empty String Trap
function f(count?: number) {
if (count) {
// 0 also fails this check
}
if (count !== undefined) {
// correct — 0 passes
}
}Same class of bug as {items.length && <List />} in React.
3. Equality Narrowing
function compare(a: string | number, b: string | boolean) {
if (a === b) {
// both narrowed to string — the only overlapping type
a.toUpperCase();
}
}Nullish Check
function f(x?: string | null) {
if (x != null) {
// != null excludes BOTH null and undefined
x.toUpperCase();
}
}!= null with loose equality is the idiomatic way to exclude both at once.
4. in Operator
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function speak(animal: Dog | Cat) {
if ("bark" in animal) {
animal.bark(); // Dog
} else {
animal.meow(); // Cat
}
}5. instanceof
function logDate(value: Date | string) {
if (value instanceof Date) {
return value.toISOString();
}
return value;
}Only works for things with a constructor — classes, Date, Error, RegExp, Map. Not for plain object types or interfaces, which don't exist at runtime.
6. Discriminated Unions
The most important pattern in this file. A shared literal field that tells the types apart.
type Success = { status: "success"; data: User[] };
type Failure = { status: "error"; message: string };
type Loading = { status: "loading" };
type State = Success | Failure | Loading;
function render(state: State) {
switch (state.status) {
case "loading":
return <Spinner />;
case "error":
return <p>{state.message}</p>; // message exists here
case "success":
return <List items={state.data} />; // data exists here
}
}TypeScript narrows on the status field automatically. You cannot accidentally read state.data in the error branch.
Why It Beats Optional Fields
// ❌ Every field optional — all four impossible states representable
type BadState = {
loading?: boolean;
data?: User[];
error?: string;
};
// You can now have loading: true AND error: "..." AND data: [...]Discriminated unions make illegal states unrepresentable. That phrase is worth saying in an interview.
With Exhaustiveness Checking
function render(state: State) {
switch (state.status) {
case "loading": return <Spinner />;
case "error": return <Error msg={state.message} />;
case "success": return <List items={state.data} />;
default: {
const _exhaustive: never = state;
throw new Error(`Unhandled: ${JSON.stringify(state)}`);
}
}
}Add a fourth state to the union and this file stops compiling until you handle it.
7. Custom Type Guards
A function whose return type is arg is Type.
type Admin = { role: "admin"; permissions: string[] };
type Guest = { role: "guest" };
function isAdmin(user: Admin | Guest): user is Admin {
return user.role === "admin";
}
if (isAdmin(user)) {
user.permissions; // narrowed
}Real Use — Validating Unknown Data
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as User).id === "number" &&
"name" in value &&
typeof (value as User).name === "string"
);
}
const data: unknown = await res.json();
if (!isUser(data)) throw new Error("Invalid response");
data.name; // safeThe Danger
A type guard is only as correct as you make it. TypeScript does not verify that the body actually checks what the signature claims.
function isUser(x: unknown): x is User {
return true; // compiles, and lies
}This is exactly why Zod exists — see section 10.
8. Assertion Functions
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Not a string");
}
}
function f(input: unknown) {
assertIsString(input);
input.toUpperCase(); // narrowed for the rest of the scope
}Also useful for non-null assertions with a real check:
function assertDefined<T>(v: T | null | undefined, msg?: string): asserts v is T {
if (v == null) throw new Error(msg ?? "Expected a value");
}
const el = document.querySelector("#app");
assertDefined(el, "#app not found");
el.classList.add("ready"); // no ! needed9. Narrowing Gotchas
Narrowing Is Lost Inside Callbacks
function f(value: string | undefined) {
if (value) {
setTimeout(() => {
value.toUpperCase(); // Error — could have changed by now
});
}
}TypeScript can't prove value is unchanged when the callback runs. Assign to a const first:
if (value) {
const v = value;
setTimeout(() => v.toUpperCase()); // OK
}Narrowing Is Lost After a Function Call
let value: string | null = getValue();
if (value !== null) {
doSomething(); // could reassign value
value.toUpperCase(); // still narrowed — TS assumes no mutation of locals
}For a let captured in a closure, TypeScript resets narrowing. Prefer const.
Object Property Narrowing
if (obj.value !== null) {
helper();
obj.value.toUpperCase(); // TS keeps the narrowing; runtime may disagree
}Destructure first — const { value } = obj — so the narrowed thing genuinely cannot change.
10. Runtime Validation with Zod
The most important practical point in this whole section: TypeScript types do not exist at runtime.
const res = await fetch("/api/user");
const user: User = await res.json(); // a lie — this is `any` in a costume
user.name.toUpperCase(); // crashes if the API changedres.json() returns any. The annotation checks nothing.
The Fix
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
createdAt: z.coerce.date(),
});
type User = z.infer<typeof UserSchema>; // type derived FROM the schema
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return UserSchema.parse(await res.json()); // throws on mismatch
}Why This Is The Right Answer
- One source of truth — the schema generates the type via
z.infer - Fails at the boundary with a clear error, not three components deep
- Same schema validates forms, API bodies and environment variables
// Validate env vars at startup — fail fast, not at 3am
const env = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
}).parse(process.env);Interview Point
"TypeScript is compile-time only, so I validate every external boundary at runtime with Zod and derive the TypeScript type from the schema."
That single sentence covers API responses, form input, env vars and third-party data — and it is the answer that shows you have debugged a real production type error.