Type narrowing refines types within conditional blocks. Here's how to use it effectively.
Basic Type Guards#
1// typeof guard
2function processValue(value: string | number) {
3 if (typeof value === 'string') {
4 // value is string here
5 return value.toUpperCase();
6 }
7 // value is number here
8 return value.toFixed(2);
9}
10
11// typeof checks
12function handleInput(input: string | number | boolean | undefined) {
13 if (typeof input === 'string') {
14 console.log(input.trim());
15 } else if (typeof input === 'number') {
16 console.log(input.toFixed(2));
17 } else if (typeof input === 'boolean') {
18 console.log(input ? 'yes' : 'no');
19 } else {
20 console.log('no input');
21 }
22}
23
24// instanceof guard
25function processDate(value: Date | string) {
26 if (value instanceof Date) {
27 // value is Date
28 return value.toISOString();
29 }
30 // value is string
31 return new Date(value).toISOString();
32}
33
34// Array.isArray
35function processItems(items: string | string[]) {
36 if (Array.isArray(items)) {
37 // items is string[]
38 return items.join(', ');
39 }
40 // items is string
41 return items;
42}Truthiness Narrowing#
1// Truthy checks narrow out null/undefined
2function greet(name: string | null | undefined) {
3 if (name) {
4 // name is string (not null/undefined/empty)
5 console.log(`Hello, ${name}!`);
6 } else {
7 console.log('Hello, stranger!');
8 }
9}
10
11// Be careful with falsy values
12function processNumber(num: number | null) {
13 if (num) {
14 // Excludes 0! May not be intended
15 console.log(num * 2);
16 }
17}
18
19// Better: explicit null check
20function processNumberSafe(num: number | null) {
21 if (num !== null) {
22 // num is number (including 0)
23 console.log(num * 2);
24 }
25}
26
27// Double negation for boolean coercion
28function hasValue(value: string | null): boolean {
29 return !!value;
30}Equality Narrowing#
1// Strict equality
2function compare(a: string | number, b: string | boolean) {
3 if (a === b) {
4 // Both are string (only common type)
5 console.log(a.toUpperCase());
6 }
7}
8
9// null/undefined checks
10function process(value: string | null | undefined) {
11 if (value !== null && value !== undefined) {
12 // value is string
13 console.log(value.length);
14 }
15
16 // Or use != null (loose equality)
17 if (value != null) {
18 // value is string (excludes both null and undefined)
19 console.log(value.length);
20 }
21}
22
23// Literal type narrowing
24type Status = 'loading' | 'success' | 'error';
25
26function handleStatus(status: Status) {
27 if (status === 'loading') {
28 // status is 'loading'
29 showSpinner();
30 } else if (status === 'success') {
31 // status is 'success'
32 showResult();
33 } else {
34 // status is 'error'
35 showError();
36 }
37}in Operator Narrowing#
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 // animal is Bird
14 animal.fly();
15 } else {
16 // animal is Fish
17 animal.swim();
18 }
19}
20
21// With optional properties
22interface Admin {
23 role: 'admin';
24 permissions: string[];
25}
26
27interface User {
28 role: 'user';
29 preferences?: string[];
30}
31
32function handlePerson(person: Admin | User) {
33 if ('permissions' in person) {
34 // person is Admin
35 console.log(person.permissions);
36 }
37}Custom Type Guards#
1// Type predicate
2interface Cat {
3 meow(): void;
4}
5
6interface Dog {
7 bark(): void;
8}
9
10function isCat(animal: Cat | Dog): animal is Cat {
11 return 'meow' in animal;
12}
13
14function handleAnimal(animal: Cat | Dog) {
15 if (isCat(animal)) {
16 animal.meow(); // TypeScript knows it's Cat
17 } else {
18 animal.bark(); // TypeScript knows it's Dog
19 }
20}
21
22// Generic type guard
23function isDefined<T>(value: T | null | undefined): value is T {
24 return value !== null && value !== undefined;
25}
26
27const items: (string | null)[] = ['a', null, 'b'];
28const defined = items.filter(isDefined);
29// defined is string[]
30
31// Type guard with assertion
32function isString(value: unknown): value is string {
33 return typeof value === 'string';
34}
35
36function assertString(value: unknown): asserts value is string {
37 if (typeof value !== 'string') {
38 throw new Error('Not a string');
39 }
40}
41
42function process(value: unknown) {
43 assertString(value);
44 // value is string after assertion
45 console.log(value.toUpperCase());
46}Discriminated Unions#
1// Common discriminant property
2interface LoadingState {
3 status: 'loading';
4}
5
6interface SuccessState {
7 status: 'success';
8 data: string;
9}
10
11interface ErrorState {
12 status: 'error';
13 error: Error;
14}
15
16type State = LoadingState | SuccessState | ErrorState;
17
18function handleState(state: State) {
19 switch (state.status) {
20 case 'loading':
21 return <Spinner />;
22 case 'success':
23 return <Result data={state.data} />; // data available
24 case 'error':
25 return <Error error={state.error} />; // error available
26 }
27}
28
29// Exhaustiveness checking
30function exhaustiveCheck(state: State): string {
31 switch (state.status) {
32 case 'loading':
33 return 'Loading...';
34 case 'success':
35 return state.data;
36 case 'error':
37 return state.error.message;
38 default:
39 // Compile error if case is missing
40 const _exhaustive: never = state;
41 throw new Error(`Unhandled state: ${_exhaustive}`);
42 }
43}Assertion Functions#
1// Assert condition
2function assert(condition: unknown, msg?: string): asserts condition {
3 if (!condition) {
4 throw new Error(msg ?? 'Assertion failed');
5 }
6}
7
8function process(value: string | null) {
9 assert(value !== null, 'Value is required');
10 // value is string
11 console.log(value.toUpperCase());
12}
13
14// Assert is type
15function assertIsString(value: unknown): asserts value is string {
16 if (typeof value !== 'string') {
17 throw new TypeError('Expected string');
18 }
19}
20
21function assertIsArray<T>(value: unknown): asserts value is T[] {
22 if (!Array.isArray(value)) {
23 throw new TypeError('Expected array');
24 }
25}
26
27// Practical usage
28interface Config {
29 apiUrl: string;
30 timeout: number;
31}
32
33function assertValidConfig(config: unknown): asserts config is Config {
34 if (typeof config !== 'object' || config === null) {
35 throw new Error('Config must be an object');
36 }
37
38 const obj = config as Record<string, unknown>;
39
40 if (typeof obj.apiUrl !== 'string') {
41 throw new Error('apiUrl must be a string');
42 }
43
44 if (typeof obj.timeout !== 'number') {
45 throw new Error('timeout must be a number');
46 }
47}Narrowing with Control Flow#
1// Early return
2function processUser(user: User | null): string {
3 if (!user) {
4 return 'No user';
5 }
6 // user is User
7 return user.name;
8}
9
10// Throw
11function requireUser(user: User | null): User {
12 if (!user) {
13 throw new Error('User required');
14 }
15 return user; // User
16}
17
18// Never type for exhaustiveness
19function handleAction(action: 'create' | 'update' | 'delete') {
20 switch (action) {
21 case 'create':
22 return create();
23 case 'update':
24 return update();
25 case 'delete':
26 return remove();
27 default:
28 const _never: never = action;
29 throw new Error(`Unknown action: ${_never}`);
30 }
31}
32
33// Loop narrowing
34function processItems(items: (string | number)[]): string[] {
35 const result: string[] = [];
36
37 for (const item of items) {
38 if (typeof item === 'string') {
39 result.push(item.toUpperCase());
40 }
41 }
42
43 return result;
44}Advanced Patterns#
1// Branded types
2type UserId = string & { readonly brand: unique symbol };
3type PostId = string & { readonly brand: unique symbol };
4
5function createUserId(id: string): UserId {
6 return id as UserId;
7}
8
9function getUser(id: UserId) {
10 // Only accepts UserId, not PostId or string
11}
12
13// Narrowing generics
14function processArray<T>(arr: T[]): T | undefined {
15 if (arr.length === 0) {
16 return undefined;
17 }
18 return arr[0];
19}
20
21// Conditional narrowing
22type ExtractString<T> = T extends string ? T : never;
23
24function filterStrings<T>(arr: T[]): ExtractString<T>[] {
25 return arr.filter((item): item is ExtractString<T> =>
26 typeof item === 'string'
27 );
28}Best Practices#
Type Guards:
✓ Use typeof for primitives
✓ Use instanceof for classes
✓ Use in for object properties
✓ Use discriminant for unions
Custom Guards:
✓ Return type predicate (is)
✓ Keep guards simple
✓ Throw for assertions
✓ Test edge cases
Control Flow:
✓ Use early returns
✓ Handle all union members
✓ Use never for exhaustiveness
✓ Be explicit about null
Avoid:
✗ Type assertions (as) instead of guards
✗ Complex nested conditions
✗ Ignoring null/undefined
✗ Assuming truthy means defined
Conclusion#
TypeScript narrowing enables type-safe conditional logic. Use built-in type guards for common cases, custom type predicates for complex checks, and discriminated unions for state management. Always handle all union members and use exhaustiveness checking to catch missing cases.