|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/type-guards
$cat docs/typescript-—-type-guards.md
updated Recently·12 min read·published

TypeScript — Type Guards

TypeScriptIntermediate
Introduction

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.

typeof Type Guard

The typeof operator checks the runtime type of a value. TypeScript narrows the type in if and else branches based on the result.

typeof.ts
TypeScript
1function 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
15function 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
33function badNarrow(x: Date | RegExp) {
34 if (typeof x === "object") {
35 // x is still Date | RegExp — typeof can't distinguish
36 }
37}
instanceof Type Guard

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.

instanceof.ts
TypeScript
1class HttpError {
2 constructor(public status: number, public message: string) {}
3}
4
5class ValidationError {
6 constructor(public field: string, public details: string) {}
7}
8
9function 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
23function 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
31class EvenNumber {
32 static [Symbol.hasInstance](value: unknown): value is number {
33 return typeof value === "number" && value % 2 === 0;
34 }
35}
36
37function 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

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.

in_operator.ts
TypeScript
1interface Bird {
2 fly(): void;
3 layEggs(): void;
4}
5
6interface Fish {
7 swim(): void;
8 layEggs(): void;
9}
10
11function 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
22interface ApiResponse {
23 data: unknown;
24}
25
26interface SuccessResponse extends ApiResponse {
27 data: Record<string, unknown>;
28 status: "success";
29}
30
31interface ErrorResponse extends ApiResponse {
32 error: string;
33 status: "error";
34}
35
36function 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)
47class Base {
48 baseProp = "base";
49}
50
51class Child extends Base {
52 childProp = "child";
53}
54
55function 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

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.

discriminated.ts
TypeScript
1// Discriminated union — "kind" is the discriminant
2type Shape =
3 | { kind: "circle"; radius: number }
4 | { kind: "rectangle"; width: number; height: number }
5 | { kind: "triangle"; base: number; height: number };
6
7function 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
19function 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
37function assertNever(x: never): never {
38 throw new Error(`Unexpected value: ${x}`);
39}
40
41function 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
55type Action =
56 | { type: "increment"; amount: number }
57 | { type: "decrement"; amount: number }
58 | { type: "reset" };
59
60function 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

Always use the never exhaustiveness check in switch statements over discriminated unions. This ensures that when you add a new variant, TypeScript forces you to handle it everywhere.
User-Defined Type Predicates (is)

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.

predicates.ts
TypeScript
1interface Cat {
2 meow(): void;
3 purr(): void;
4}
5
6interface Dog {
7 bark(): void;
8 fetch(): void;
9}
10
11// Type predicate: animal is Cat
12function isCat(animal: Cat | Dog): animal is Cat {
13 return "meow" in animal;
14}
15
16function 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
29interface User {
30 id: number;
31 name: string;
32 email?: string;
33 phone?: string;
34}
35
36function hasEmail(user: User): user is User & { email: string } {
37 return user.email !== undefined && user.email.length > 0;
38}
39
40function hasPhone(user: User): user is User & { phone: string } {
41 return user.phone !== undefined && user.phone.length > 0;
42}
43
44function 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
54const mixed: (string | number | null)[] = [1, null, "hello", null, 42];
55
56const nonNull = mixed.filter((x): x is string | number => x !== null);
57// Type: (string | number)[]
58
59const onlyStrings = mixed.filter((x): x is string => typeof x === "string");
60// Type: string[]
61
62// Generic type predicate
63function isDefined<T>(value: T | null | undefined): value is T {
64 return value !== null && value !== undefined;
65}
66
67const items = [1, null, 2, undefined, 3];
68const valid = items.filter(isDefined);
69// Type: number[]

warning

Type predicates are a trust contract — TypeScript believes your return value without verification. If your predicate logic is wrong, you'll get runtime errors. Always write clear tests for type predicate functions.
Assertion Functions (asserts)

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.

assertions.ts
TypeScript
1// Assertion function — throws if condition fails
2function assertString(value: unknown): asserts value is string {
3 if (typeof value !== "string") {
4 throw new TypeError(`Expected string, got ${typeof value}`);
5 }
6}
7
8function assertNumber(value: unknown): asserts value is number {
9 if (typeof value !== "number") {
10 throw new TypeError(`Expected number, got ${typeof value}`);
11 }
12}
13
14function 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
20interface User {
21 id: number;
22 name: string;
23 email: string;
24 role: "admin" | "user";
25}
26
27function 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
39function 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
46function 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
55function 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}
Best Practices

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.

$Blueprint — Engineering Documentation·Section ID: TS-GUARD·Revision: 1.0