TypeScript — Type Guards
Type guards are expressions that narrow the type of a value within a conditional block. TypeScript's control flow analysis automatically narrows types when you check them with typeof, instanceof, equality checks, or custom predicates. This is the foundation of writing safe code with union types.
The typeof operator checks the runtime type of a value. TypeScript narrows the type in if and else branches based on the result.
| 1 | function processValue(value: string | number | boolean) { |
| 2 | if (typeof value === "string") { |
| 3 | // value is narrowed to string |
| 4 | console.log(value.toUpperCase()); |
| 5 | } else if (typeof value === "number") { |
| 6 | // value is narrowed to number |
| 7 | console.log(value.toFixed(2)); |
| 8 | } else { |
| 9 | // value is narrowed to boolean |
| 10 | console.log(value ? "yes" : "no"); |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | // typeof with unknown |
| 15 | function parseInput(input: unknown): string { |
| 16 | if (typeof input === "string") { |
| 17 | return input.trim(); |
| 18 | } |
| 19 | if (typeof input === "number") { |
| 20 | return input.toString(); |
| 21 | } |
| 22 | if (typeof input === "boolean") { |
| 23 | return input ? "true" : "false"; |
| 24 | } |
| 25 | throw new Error("Unsupported type"); |
| 26 | } |
| 27 | |
| 28 | // typeof works for primitive types only |
| 29 | // It returns: "string" | "number" | "bigint" | "boolean" | "undefined" |
| 30 | // | "object" | "function" | "symbol" | "bigint" |
| 31 | |
| 32 | // typeof cannot narrow to specific object types |
| 33 | function badNarrow(x: Date | RegExp) { |
| 34 | if (typeof x === "object") { |
| 35 | // x is still Date | RegExp — typeof can't distinguish |
| 36 | } |
| 37 | } |
The instanceof operator checks if a value is an instance of a class. It narrows to the class type in the true branch and to all other types in the false branch.
| 1 | class HttpError { |
| 2 | constructor(public status: number, public message: string) {} |
| 3 | } |
| 4 | |
| 5 | class ValidationError { |
| 6 | constructor(public field: string, public details: string) {} |
| 7 | } |
| 8 | |
| 9 | function handleError(error: HttpError | ValidationError | Error) { |
| 10 | if (error instanceof HttpError) { |
| 11 | // narrowed to HttpError |
| 12 | console.log(`HTTP ${error.status}: ${error.message}`); |
| 13 | } else if (error instanceof ValidationError) { |
| 14 | // narrowed to ValidationError |
| 15 | console.log(`Validation ${error.field}: ${error.details}`); |
| 16 | } else { |
| 17 | // narrowed to Error (base class) |
| 18 | console.log(error.message); |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | // instanceof with built-in types |
| 23 | function processDateOrString(input: Date | string) { |
| 24 | if (input instanceof Date) { |
| 25 | return input.toISOString(); |
| 26 | } |
| 27 | return new Date(input).toISOString(); |
| 28 | } |
| 29 | |
| 30 | // instanceof with Symbol.hasInstance |
| 31 | class EvenNumber { |
| 32 | static [Symbol.hasInstance](value: unknown): value is number { |
| 33 | return typeof value === "number" && value % 2 === 0; |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | function check(n: number) { |
| 38 | if (n instanceof EvenNumber) { |
| 39 | // narrowed to number, and we know it's even |
| 40 | console.log(`${n} is even`); |
| 41 | } |
| 42 | } |
The in operator checks if a property exists on an object. TypeScript uses it to narrow union types of objects — the true branch narrows to the type that has that property.
| 1 | interface Bird { |
| 2 | fly(): void; |
| 3 | layEggs(): void; |
| 4 | } |
| 5 | |
| 6 | interface Fish { |
| 7 | swim(): void; |
| 8 | layEggs(): void; |
| 9 | } |
| 10 | |
| 11 | function move(animal: Bird | Fish) { |
| 12 | if ("fly" in animal) { |
| 13 | // narrowed to Bird |
| 14 | animal.fly(); |
| 15 | } else { |
| 16 | // narrowed to Fish |
| 17 | animal.swim(); |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | // in with more complex types |
| 22 | interface ApiResponse { |
| 23 | data: unknown; |
| 24 | } |
| 25 | |
| 26 | interface SuccessResponse extends ApiResponse { |
| 27 | data: Record<string, unknown>; |
| 28 | status: "success"; |
| 29 | } |
| 30 | |
| 31 | interface ErrorResponse extends ApiResponse { |
| 32 | error: string; |
| 33 | status: "error"; |
| 34 | } |
| 35 | |
| 36 | function handleResponse(res: SuccessResponse | ErrorResponse) { |
| 37 | if ("error" in res) { |
| 38 | // narrowed to ErrorResponse |
| 39 | console.error(res.error); |
| 40 | } else { |
| 41 | // narrowed to SuccessResponse |
| 42 | console.log(res.data); |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // in checks own properties (not inherited) |
| 47 | class Base { |
| 48 | baseProp = "base"; |
| 49 | } |
| 50 | |
| 51 | class Child extends Base { |
| 52 | childProp = "child"; |
| 53 | } |
| 54 | |
| 55 | function check(obj: Base | { other: string }) { |
| 56 | if ("childProp" in obj) { |
| 57 | // narrowed to Child (has childProp as own property) |
| 58 | } else { |
| 59 | // narrowed to Base | { other: string } |
| 60 | } |
| 61 | } |
Discriminated unions use a common literal property (the discriminant) to distinguish between union members. This pattern enables exhaustive type narrowing with switch statements and the never type.
| 1 | // Discriminated union — "kind" is the discriminant |
| 2 | type Shape = |
| 3 | | { kind: "circle"; radius: number } |
| 4 | | { kind: "rectangle"; width: number; height: number } |
| 5 | | { kind: "triangle"; base: number; height: number }; |
| 6 | |
| 7 | function area(shape: Shape): number { |
| 8 | switch (shape.kind) { |
| 9 | case "circle": |
| 10 | return Math.PI * shape.radius ** 2; |
| 11 | case "rectangle": |
| 12 | return shape.width * shape.height; |
| 13 | case "triangle": |
| 14 | return (shape.base * shape.height) / 2; |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | // Exhaustive check with never |
| 19 | function areaExhaustive(shape: Shape): number { |
| 20 | switch (shape.kind) { |
| 21 | case "circle": |
| 22 | return Math.PI * shape.radius ** 2; |
| 23 | case "rectangle": |
| 24 | return shape.width * shape.height; |
| 25 | case "triangle": |
| 26 | return (shape.base * shape.height) / 2; |
| 27 | default: |
| 28 | const _exhaustive: never = shape; |
| 29 | return _exhaustive; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // If you add a new variant to Shape but forget to handle it, |
| 34 | // TypeScript will error on the never assignment. |
| 35 | |
| 36 | // Exhaustive check helper |
| 37 | function assertNever(x: never): never { |
| 38 | throw new Error(`Unexpected value: ${x}`); |
| 39 | } |
| 40 | |
| 41 | function areaWithAssert(shape: Shape): number { |
| 42 | switch (shape.kind) { |
| 43 | case "circle": |
| 44 | return Math.PI * shape.radius ** 2; |
| 45 | case "rectangle": |
| 46 | return shape.width * shape.height; |
| 47 | case "triangle": |
| 48 | return (shape.base * shape.height) / 2; |
| 49 | default: |
| 50 | return assertNever(shape); |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // Discriminated union with multiple discriminants |
| 55 | type Action = |
| 56 | | { type: "increment"; amount: number } |
| 57 | | { type: "decrement"; amount: number } |
| 58 | | { type: "reset" }; |
| 59 | |
| 60 | function reducer(state: number, action: Action): number { |
| 61 | switch (action.type) { |
| 62 | case "increment": |
| 63 | return state + action.amount; |
| 64 | case "decrement": |
| 65 | return state - action.amount; |
| 66 | case "reset": |
| 67 | return 0; |
| 68 | } |
| 69 | } |
best practice
A type predicate is a function return type of the form parameterName is Type. It tells TypeScript that if the function returns true, the parameter is narrowed to the specified type.
| 1 | interface Cat { |
| 2 | meow(): void; |
| 3 | purr(): void; |
| 4 | } |
| 5 | |
| 6 | interface Dog { |
| 7 | bark(): void; |
| 8 | fetch(): void; |
| 9 | } |
| 10 | |
| 11 | // Type predicate: animal is Cat |
| 12 | function isCat(animal: Cat | Dog): animal is Cat { |
| 13 | return "meow" in animal; |
| 14 | } |
| 15 | |
| 16 | function interact(animal: Cat | Dog) { |
| 17 | if (isCat(animal)) { |
| 18 | // narrowed to Cat |
| 19 | animal.meow(); |
| 20 | animal.purr(); |
| 21 | } else { |
| 22 | // narrowed to Dog |
| 23 | animal.bark(); |
| 24 | animal.fetch(); |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | // Type predicate with multiple conditions |
| 29 | interface User { |
| 30 | id: number; |
| 31 | name: string; |
| 32 | email?: string; |
| 33 | phone?: string; |
| 34 | } |
| 35 | |
| 36 | function hasEmail(user: User): user is User & { email: string } { |
| 37 | return user.email !== undefined && user.email.length > 0; |
| 38 | } |
| 39 | |
| 40 | function hasPhone(user: User): user is User & { phone: string } { |
| 41 | return user.phone !== undefined && user.phone.length > 0; |
| 42 | } |
| 43 | |
| 44 | function notify(user: User) { |
| 45 | if (hasEmail(user)) { |
| 46 | console.log(`Email: ${user.email}`); // email is string |
| 47 | } |
| 48 | if (hasPhone(user)) { |
| 49 | console.log(`Phone: ${user.phone}`); // phone is string |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // Filtering arrays with type predicates |
| 54 | const mixed: (string | number | null)[] = [1, null, "hello", null, 42]; |
| 55 | |
| 56 | const nonNull = mixed.filter((x): x is string | number => x !== null); |
| 57 | // Type: (string | number)[] |
| 58 | |
| 59 | const onlyStrings = mixed.filter((x): x is string => typeof x === "string"); |
| 60 | // Type: string[] |
| 61 | |
| 62 | // Generic type predicate |
| 63 | function isDefined<T>(value: T | null | undefined): value is T { |
| 64 | return value !== null && value !== undefined; |
| 65 | } |
| 66 | |
| 67 | const items = [1, null, 2, undefined, 3]; |
| 68 | const valid = items.filter(isDefined); |
| 69 | // Type: number[] |
warning
Assertion functions throw an error if the condition is false. The return type asserts value is Type tells TypeScript that if the function returns, the value is narrowed.
| 1 | // Assertion function — throws if condition fails |
| 2 | function assertString(value: unknown): asserts value is string { |
| 3 | if (typeof value !== "string") { |
| 4 | throw new TypeError(`Expected string, got ${typeof value}`); |
| 5 | } |
| 6 | } |
| 7 | |
| 8 | function assertNumber(value: unknown): asserts value is number { |
| 9 | if (typeof value !== "number") { |
| 10 | throw new TypeError(`Expected number, got ${typeof value}`); |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | function process(input: unknown) { |
| 15 | assertString(input); // throws if not string |
| 16 | console.log(input.toUpperCase()); // narrowed to string |
| 17 | } |
| 18 | |
| 19 | // Assertion with complex conditions |
| 20 | interface User { |
| 21 | id: number; |
| 22 | name: string; |
| 23 | email: string; |
| 24 | role: "admin" | "user"; |
| 25 | } |
| 26 | |
| 27 | function assertValidUser(data: unknown): asserts data is User { |
| 28 | const obj = data as Record<string, unknown>; |
| 29 | if ( |
| 30 | typeof obj?.id !== "number" || |
| 31 | typeof obj?.name !== "string" || |
| 32 | typeof obj?.email !== "string" || |
| 33 | (obj?.role !== "admin" && obj?.role !== "user") |
| 34 | ) { |
| 35 | throw new Error("Invalid user data"); |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | function handleData(raw: unknown) { |
| 40 | assertValidUser(raw); |
| 41 | // raw is narrowed to User |
| 42 | console.log(raw.name, raw.role); |
| 43 | } |
| 44 | |
| 45 | // Assertion guard with array filtering |
| 46 | function assertDefined<T>( |
| 47 | value: T | null | undefined, |
| 48 | name: string |
| 49 | ): asserts value is T { |
| 50 | if (value === null || value === undefined) { |
| 51 | throw new Error(`${name} is required`); |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | function buildConfig( |
| 56 | host: string | undefined, |
| 57 | port: number | undefined |
| 58 | ) { |
| 59 | assertDefined(host, "host"); |
| 60 | assertDefined(port, "port"); |
| 61 | // host is string, port is number — narrowed after asserts |
| 62 | return { host, port }; |
| 63 | } |
Type guards are essential for writing safe TypeScript code. Follow these patterns to keep narrowing predictable and maintainable.
Prefer discriminated unions.They give you automatic narrowing with switch statements, exhaustive checking, and don't require runtime checks or type predicates.
Use typeof for primitives, instanceof for classes.These are the simplest and most reliable built-in guards. Don't overcomplicate things.
Write type predicates carefully.They bypass TypeScript's safety checks. A wrong predicate leads to silent runtime errors. Always test predicate functions.
Always handle the never case. Use the never exhaustiveness check after switch statements on discriminated unions. It catches missing variants at compile time.
Use assertion functions for validation. When parsing external data (API responses, user input), assertion functions narrow the type and throw on invalid data, combining runtime safety with compile-time narrowing.