Skip to content

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

ts
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

ts
function f(x: object | null) {
  if (typeof x === "object") {
    // x is still object | null — typeof null === "object"
  }
}

Check for null explicitly.


2. Truthiness Narrowing

ts
function greet(name?: string) {
  if (name) {
    return `Hello ${name}`;   // string
  }
  return "Hello stranger";
}

The Empty String Trap

ts
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

ts
function compare(a: string | number, b: string | boolean) {
  if (a === b) {
    // both narrowed to string — the only overlapping type
    a.toUpperCase();
  }
}

Nullish Check

ts
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

ts
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

ts
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.

ts
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

ts
// ❌ 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

ts
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.

ts
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

ts
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;   // safe

The 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.

ts
function isUser(x: unknown): x is User {
  return true;   // compiles, and lies
}

This is exactly why Zod exists — see section 10.


8. Assertion Functions

ts
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:

ts
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 ! needed

9. Narrowing Gotchas

Narrowing Is Lost Inside Callbacks

ts
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:

ts
if (value) {
  const v = value;
  setTimeout(() => v.toUpperCase());   // OK
}

Narrowing Is Lost After a Function Call

ts
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

ts
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.

ts
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 changed

res.json() returns any. The annotation checks nothing.

The Fix

ts
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
ts
// 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.

© 2025 DDocs · Dipak's Documentation Guide