Back to Blog
TypeScriptTypesType GuardsNarrowing

TypeScript Narrowing Techniques

Master TypeScript type narrowing. From type guards to discriminated unions to assertion functions.

B
Bootspring Team
Engineering
September 18, 2020
7 min read

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.

Share this article

Help spread the word about Bootspring