Type Narrowing in TypeScript
What is Type Narrowing?
Type Narrowing is the process by which TypeScript refines a variable's type from a more general to a more specific one based on checks in the code.
function process(value: string | number) {
// Here value: string | number
if (typeof value === 'string') {
// Here value: string (type narrowed!)
console.log(value.toUpperCase());
} else {
// Here value: number (only number remains)
console.log(value.toFixed(2));
}
}
Ways to Narrow Types
typeof Guard
Checking primitive types via typeof.
function printValue(value: string | number | boolean) {
if (typeof value === 'string') {
console.log(value.toUpperCase());
} else if (typeof value === 'number') {
console.log(value.toFixed(2));
} else {
console.log(value ? 'true' : 'false');
}
}
Important:
typeof null returns 'object', this is a JavaScript feature!
function process(value: string | null) {
if (typeof value === 'object') {
// Here value is still string | null (null is object!)
console.log(value); // can be null
}
}
instanceof Guard
Checking class membership.
class Dog {
bark() {
console.log('Woof!');
}
}
class Cat {
meow() {
console.log('Meow!');
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark(); // animal: Dog
} else {
animal.meow(); // animal: Cat
}
}
Working with Built-in Classes
function processValue(value: Date | string) {
if (value instanceof Date) {
console.log(value.getFullYear()); // value: Date
} else {
console.log(value.toUpperCase()); // value: string
}
}
in Operator
Checking property presence in object.
interface Circle {
radius: number;
}
interface Square {
size: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape) {
if ('radius' in shape) {
// shape: Circle
return Math.PI * shape.radius ** 2;
} else {
// shape: Square
return shape.size ** 2;
}
}
Checking Methods
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
function move(animal: Bird | Fish) {
if ('fly' in animal) {
animal.fly(); // animal: Bird
} else {
animal.swim(); // animal: Fish
}
}
Equality Narrowing
Narrowing through equality check.
function process(x: string | number, y: string | boolean) {
if (x === y) {
// x and y can only be equal if both are string
console.log(x.toUpperCase()); // x: string
console.log(y.toUpperCase()); // y: string
}
}
Checking for null and undefined
function printName(name: string | null | undefined) {
if (name !== null && name !== undefined) {
console.log(name.toUpperCase()); // name: string
}
// Or shorter
if (name != null) {
console.log(name.toUpperCase()); // name: string
}
}
Truthiness Narrowing
Narrowing based on truthy/falsy check.
function printLength(str: string | null | undefined) {
if (str) {
// str: string (removed null and undefined)
console.log(str.length);
}
}
Falsy Values
function process(value: string | number | null | undefined | 0 | '') {
if (value) {
// value: string | number (removed falsy values)
// BUT! 0 and '' are also falsy, so they're removed too
}
}
Caution:
Truthiness narrowing removes ALL falsy values: 0, '', false, null, undefined, NaN.
More Precise Check
function processValue(value: string | null) {
if (value !== null) {
// value: string
console.log(value.length);
}
}
Type Predicates (is)
Custom type guards with is keyword.
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function process(value: unknown) {
if (isString(value)) {
// value: string
console.log(value.toUpperCase());
}
}
More Complex Checks
interface User {
name: string;
email: string;
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'email' in obj &&
typeof (obj as User).name === 'string' &&
typeof (obj as User).email === 'string'
);
}
function greetUser(data: unknown) {
if (isUser(data)) {
// data: User
console.log(`Hello, ${data.name}!`);
}
}
Discriminated Unions
Narrowing based on common discriminating property.
type Success = {
status: 'success';
data: string;
};
type Error = {
status: 'error';
message: string;
};
type Result = Success | Error;
function handleResult(result: Result) {
if (result.status === 'success') {
// result: Success
console.log(result.data);
} else {
// result: Error
console.log(result.message);
}
}
Switch Statement
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET'; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT':
// action: { type: 'INCREMENT' }
return state + 1;
case 'DECREMENT':
// action: { type: 'DECREMENT' }
return state - 1;
case 'SET':
// action: { type: 'SET'; value: number }
return action.value;
}
}
Assignment Narrowing
Narrowing on assignment.
let value: string | number;
value = 'hello';
// value: string (narrowed to string)
console.log(value.toUpperCase());
value = 42;
// value: number (narrowed to number)
console.log(value.toFixed(2));
Control Flow Analysis
TypeScript analyzes code execution flow.
function process(value: string | null) {
if (value === null) {
return;
}
// value: string (null excluded after return)
console.log(value.toUpperCase());
}
Throw Statements
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Not a string!');
}
}
function process(value: unknown) {
assertIsString(value);
// value: string (after assertion)
console.log(value.toUpperCase());
}
Practical Examples
Handling API Response
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
async function fetchUser(id: number): Promise<ApiResponse<User>> {
// ...
}
const response = await fetchUser(1);
if (response.success) {
// response: { success: true; data: User }
console.log(response.data.name);
} else {
// response: { success: false; error: string }
console.error(response.error);
}
Validating Form Fields
interface FormData {
name?: string;
email?: string;
age?: number;
}
function validateForm(data: FormData): boolean {
if (!data.name) {
console.error('Name is required');
return false;
}
// data.name: string (not undefined)
if (data.name.length < 3) {
console.error('Name too short');
return false;
}
if (!data.email) {
console.error('Email is required');
return false;
}
// data.email: string
if (!data.email.includes('@')) {
console.error('Invalid email');
return false;
}
return true;
}
Working with Events
function handleEvent(event: MouseEvent | KeyboardEvent) {
if (event instanceof MouseEvent) {
console.log(`Mouse: ${event.clientX}, ${event.clientY}`);
} else {
console.log(`Key: ${event.key}`);
}
}
Array.isArray()
function process(value: string | string[]) {
if (Array.isArray(value)) {
// value: string[]
value.forEach(item => console.log(item));
} else {
// value: string
console.log(value);
}
}
Never Type and Exhaustiveness
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.size ** 2;
default:
// shape: never (all cases handled)
const _exhaustive: never = shape;
return _exhaustive;
}
}
If we add a new type, TypeScript will error:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'triangle'; base: number; height: number };
// Error in default case!
Type Narrowing Limitations
Variable Changes in Callbacks
function process(value: string | null) {
if (value !== null) {
setTimeout(() => {
// Error! value might have changed
console.log(value.toUpperCase());
}, 1000);
}
value = null; // Changed!
}
Object Mutations
interface Container {
value: string | number;
}
function process(container: Container) {
if (typeof container.value === 'string') {
setTimeout(() => {
// Error! value could have changed
console.log(container.value.toUpperCase());
}, 0);
}
}
Best Practices
Use Type Guards for Complex Checks
// Bad
function process(data: unknown) {
if (
typeof data === 'object' &&
data !== null &&
'name' in data &&
typeof (data as any).name === 'string'
) {
console.log((data as { name: string }).name);
}
}
// Good
function isUser(data: unknown): data is { name: string } {
return (
typeof data === 'object' &&
data !== null &&
'name' in data &&
typeof (data as any).name === 'string'
);
}
function process(data: unknown) {
if (isUser(data)) {
console.log(data.name);
}
}
Early Return for Simplification
// Bad
function process(value: string | null) {
if (value !== null) {
console.log(value.toUpperCase());
console.log(value.length);
// lots of code...
}
}
// Good
function process(value: string | null) {
if (value === null) return;
// value: string throughout function
console.log(value.toUpperCase());
console.log(value.length);
// lots of code...
}
Use Discriminated Unions
// Bad
interface Result {
success: boolean;
data?: string;
error?: string;
}
// Good
type Result =
| { success: true; data: string }
| { success: false; error: string };
Conclusion
Type Narrowing:
- Automatic type refinement based on checks
typeoffor primitivesinstanceoffor classesinfor object properties- Equality and truthiness narrowing
- Type predicates (
is) for custom checks - Discriminated unions for states
- Control flow analysis tracks execution flow
- Never for exhaustiveness checking
In Interviews:
Important to be able to:
- Explain type narrowing concept
- Show different narrowing methods (typeof, instanceof, in)
- Write type guard with
is - Explain discriminated unions
- Show exhaustiveness checking via never
- Discuss limitations (callbacks, mutations)