Type Guards & Narrowing
Narrowing is where TypeScript's type system meets runtime reality. The compiler tracks how your code refines types through control flow, and understanding this mechanism deeply is what lets you write code that is both safe and ergonomic â no casts needed.
Control Flow Narrowing
TypeScript's control flow analysis narrows types automatically through several mechanisms:
typeof Guards
function format(value: string | number | boolean): string {
if (typeof value === "string") {
return value.toUpperCase(); // value: string
}
if (typeof value === "number") {
return value.toFixed(2); // value: number
}
return value ? "yes" : "no"; // value: boolean
}typeof works for primitives: "string", "number", "boolean", "bigint", "symbol", "undefined", "function", and "object".
instanceof Guards
function processError(error: Error | string): string {
if (error instanceof TypeError) {
return `Type error: ${error.message}`; // error: TypeError
}
if (error instanceof Error) {
return error.message; // error: Error
}
return error; // error: string
}Truthiness Narrowing
function greet(name: string | null | undefined): string {
if (name) {
return `Hello, ${name}`; // name: string (null and undefined eliminated)
}
return "Hello, stranger";
}Pitfall: Truthiness narrowing eliminates "", 0, NaN, null, undefined, and false. If 0 or "" are valid values, use explicit null checks instead.
Equality Narrowing
function compare(a: string | number, b: string | boolean) {
if (a === b) {
// Both narrowed to `string` â the only overlapping type
console.log(a.toUpperCase());
}
}in Operator Narrowing
type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Dog = { swim: () => void; bark: () => void };
function move(animal: Fish | Bird | Dog) {
if ("bark" in animal) {
animal.bark(); // animal: Dog
} else if ("swim" in animal) {
animal.swim(); // animal: Fish (Dog already eliminated)
} else {
animal.fly(); // animal: Bird
}
}Type Predicates (is keyword)
Custom type guards use the is keyword to tell the compiler what a function proves:
interface Cat {
type: "cat";
purr(): void;
}
interface Dog {
type: "dog";
bark(): void;
}
type Animal = Cat | Dog;
function isCat(animal: Animal): animal is Cat {
return animal.type === "cat";
}
function interact(animal: Animal) {
if (isCat(animal)) {
animal.purr(); // â
narrowed to Cat
} else {
animal.bark(); // â
narrowed to Dog
}
}Type Predicates with Arrays
Type predicates shine with array filtering:
function isNotNull<T>(value: T | null | undefined): value is T {
return value != null;
}
const mixed: (string | null)[] = ["hello", null, "world", null];
const strings: string[] = mixed.filter(isNotNull); // â
correctly typedWithout the type predicate, filter(x => x !== null) returns (string | null)[] â TypeScript can't narrow through callbacks without predicates.
The Danger of Lying Type Guards
Type predicates are assertions to the compiler â the compiler trusts you completely:
function isString(value: unknown): value is string {
return typeof value === "number"; // BUG: lying to the compiler
}
const value: unknown = 42;
if (isString(value)) {
value.toUpperCase(); // TypeScript believes this is safe â RUNTIME CRASH
}A lying type guard is worse than any because it creates a false sense of safety. Always ensure the runtime check matches the type assertion.
Assertion Functions (asserts keyword)
Assertion functions narrow in the rest of the scope, not just inside an if block:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function process(input: unknown) {
assertIsString(input);
// From here on, input is `string` â no if/else needed
console.log(input.toUpperCase());
}Assertion Functions for Invariants
function assertDefined<T>(
value: T | null | undefined,
message?: string
): asserts value is T {
if (value == null) {
throw new Error(message ?? "Value is null or undefined");
}
}
function processUser(userId: string) {
const user = users.get(userId);
assertDefined(user, `User ${userId} not found`);
// user is narrowed to User (non-nullable) for the rest of the function
console.log(user.name);
}Discriminated Unions â The Canonical Pattern
Discriminated unions are the most important narrowing pattern in production TypeScript. The discriminant property (usually called kind, type, or status) must be a literal type.
State Management
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderUser(state: AsyncState<User>): string {
switch (state.status) {
case "idle":
return "Ready";
case "loading":
return "Loading...";
case "success":
return `Hello, ${state.data.name}`; // â
data available
case "error":
return `Error: ${state.error.message}`; // â
error available
}
}API Responses
type APIResponse<T> =
| { ok: true; data: T; statusCode: number }
| { ok: false; error: string; statusCode: number };
async function fetchUser(id: string): Promise<APIResponse<User>> {
const res = await fetch(`/api/users/${id}`);
if (res.ok) {
return { ok: true, data: await res.json(), statusCode: res.status };
}
return { ok: false, error: await res.text(), statusCode: res.status };
}
const result = await fetchUser("123");
if (result.ok) {
console.log(result.data.name); // â
TypeScript knows data exists
} else {
console.error(result.error); // â
TypeScript knows error exists
}Form Validation
type ValidationResult<T> =
| { valid: true; data: T }
| { valid: false; errors: Record<string, string[]> };
function validateEmail(input: string): ValidationResult<string> {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(input)) {
return { valid: true, data: input };
}
return { valid: false, errors: { email: ["Invalid email format"] } };
}Exhaustive Checking with never
The never type ensures you've handled every variant:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}If you add { kind: "polygon"; sides: number; sideLength: number } to Shape but forget to handle it in the switch, the assertNever(shape) call will fail at compile time â shape is narrowed to the polygon variant, which isn't assignable to never.
Branded / Opaque Types
TypeScript's structural typing means string is string â there's no compile-time distinction between a user ID, an email, and a random string. Branded types fix this:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
type NonEmptyString = Brand<string, "NonEmptyString">;
function createUserId(id: string): UserId {
if (!id.match(/^usr_[a-z0-9]{8}$/)) {
throw new Error("Invalid user ID format");
}
return id as UserId;
}
function createEmail(email: string): Email {
if (!email.includes("@")) throw new Error("Invalid email");
return email as Email;
}
function sendEmail(to: Email, subject: string): void {}
function getUser(id: UserId): User | null { return null; }
const userId = createUserId("usr_abc12345");
const email = createEmail("ada@example.com");
sendEmail(email, "Welcome!"); // â
sendEmail(userId, "Welcome!"); // â UserId is not assignable to Email
getUser(email); // â Email is not assignable to UserId
getUser("random-string"); // â string is not assignable to UserIdThe brand property (__brand) never exists at runtime â it's a phantom type that exists only in the type system. The validation functions serve as the "constructors" that enforce the brand.
Branded Numeric Types
type Celsius = Brand<number, "Celsius">;
type Fahrenheit = Brand<number, "Fahrenheit">;
function celsiusToFahrenheit(c: Celsius): Fahrenheit {
return ((c * 9) / 5 + 32) as Fahrenheit;
}
const temp = 100 as Celsius;
celsiusToFahrenheit(temp); // â
celsiusToFahrenheit(100 as Fahrenheit); // â prevents unit confusionThis pattern prevents the kind of unit confusion that famously caused the Mars Climate Orbiter crash.
Narrowing with satisfies
type Route = { path: string; auth: boolean };
type Routes = Record<string, Route>;
const routes = {
home: { path: "/", auth: false },
dashboard: { path: "/dashboard", auth: true },
profile: { path: "/profile", auth: true },
} satisfies Routes;
// Each value is narrowed to its literal shape, not widened to Route
routes.home.path; // type: "/" (not string)
routes.dashboard.auth; // type: true (not boolean)Pattern Matching (TC39 Proposal)
While not yet in TypeScript, the TC39 pattern matching proposal will bring first-class pattern matching. Today, libraries like ts-pattern provide it:
import { match, P } from "ts-pattern";
type Input = { type: "text"; value: string } | { type: "number"; value: number };
const result = match(input)
.with({ type: "text", value: P.select() }, (text) => text.toUpperCase())
.with({ type: "number", value: P.when(n => n > 0) }, ({ value }) => `+${value}`)
.with({ type: "number" }, ({ value }) => String(value))
.exhaustive();Interview Power Moves
-
"Discriminated unions with exhaustive checking make illegal states unrepresentable at compile time" â the most important sentence in TypeScript architecture.
-
"I use branded types for domain primitives â
UserId,Email,NonEmptyStringâ because structural typing alone can't prevent passing the wrong string to the wrong function." -
"Assertion functions are underused. They narrow for the rest of the scope, not just inside an if-block, which makes validation code much cleaner than chains of if-returns."
-
"A lying type guard is worse than
anybecause it creates a false sense of safety. I always audit custom type predicates in code review." -
"The combination of
satisfiesandas constgives you the best of all worlds: type validation, precise inference, and immutability."