TypeScript's type system is powerful, but what makes it truly special is how it narrows types based on your code's control flow. This feature, called type narrowing (or refinement), lets you write code that's both type-safe and elegant.
What is Type Narrowing?
Type narrowing is the process by which TypeScript goes from a broad type to a more specific one based on the code's execution path. The most common example is null checking:
const elem = document.getElementById('my-element');
// elem is HTMLElement | null
if (elem) {
elem.innerHTML = 'Hello!';
// elem is HTMLElement (null is excluded)
} else {
// elem is null
console.log('Element not found');
}
Because the compiler follows the paths of execution of your code, this is also known as control flow analysis.
The Key Insight: Types Change by Location
Here's a crucial concept that sets TypeScript apart from languages like Java or C++:
flowchart TD
A["const elem = getElementById()"] --> B["elem: HTMLElement | null"]
B --> C{if elem}
C -->|true| D["elem: HTMLElement"]
C -->|false| E["elem: null"]
D --> F["Use elem safely"]
E --> G["Handle null case"]
style B fill:#f59e0b,color:#fff
style D fill:#10b981,color:#fff
style E fill:#ef4444,color:#fff
In TypeScript, a symbol has a type at a location. The same variable can have different types at different points in your code. This is unusual among programming languages but incredibly powerful.
Ways to Narrow Types
1. Throwing or Returning Early
function processValue(value: string | null) {
if (!value) {
throw new Error('Value is required');
}
// value is now string
return value.toUpperCase();
}
2. Using typeof
function formatValue(value: string | number) {
if (typeof value === 'string') {
return value.toUpperCase();
// value is string
}
return value.toFixed(2);
// value is number
}
3. Using instanceof
function processInput(input: Date | string) {
if (input instanceof Date) {
return input.toISOString();
// input is Date
}
return new Date(input).toISOString();
// input is string
}
4. Property Checks with in
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
function move(animal: Bird | Fish) {
if ('fly' in animal) {
animal.fly();
// animal is Bird
} else {
animal.swim();
// animal is Fish
}
}
5. Using Array.isArray()
function processItems(items: string | string[]) {
const list = Array.isArray(items) ? items : [items];
// list is string[]
return list.join(', ');
}
Tagged Unions: The Most Powerful Pattern
One of the most effective ways to narrow types is using tagged unions (also called discriminated unions):
interface UploadEvent {
type: 'upload';
filename: string;
contents: string;
}
interface DownloadEvent {
type: 'download';
filename: string;
}
type AppEvent = UploadEvent | DownloadEvent;
function handleEvent(event: AppEvent) {
switch (event.type) {
case 'upload':
// event is UploadEvent
console.log(`Uploading ${event.filename}: ${event.contents.length} bytes`);
break;
case 'download':
// event is DownloadEvent
console.log(`Downloading ${event.filename}`);
break;
}
}
flowchart LR
A["event: AppEvent"] --> B{event.type}
B -->|"'upload'"| C["event: UploadEvent"]
B -->|"'download'"| D["event: DownloadEvent"]
style A fill:#f59e0b,color:#fff
style C fill:#10b981,color:#fff
style D fill:#10b981,color:#fff
The type property acts as a "tag" that TypeScript uses to discriminate between union members.
User-Defined Type Guards
When TypeScript can't figure out a type on its own, you can help it with a user-defined type guard:
interface User {
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'name' in value &&
'email' in value &&
typeof (value as User).name === 'string' &&
typeof (value as User).email === 'string'
);
}
function greet(data: unknown) {
if (isUser(data)) {
// data is User
console.log(`Hello, ${data.name}!`);
}
}
The value is User return type is called a type predicate. It tells TypeScript that if the function returns true, the parameter is of the specified type.
Common Pitfalls
Pitfall 1: typeof null is "object"
function process(value: object | null) {
if (typeof value === 'object') {
// value is still object | null!
// typeof null === 'object' in JavaScript
}
}
Use explicit null checks instead:
function process(value: object | null) {
if (value !== null) {
// value is object
}
}
Pitfall 2: Falsy Values
function process(value: string | number | null) {
if (!value) {
// value could be '', 0, or null!
}
}
Be explicit about what you're checking:
function process(value: string | number | null) {
if (value === null) {
// value is null
} else {
// value is string | number (including '' and 0)
}
}
Pitfall 3: Callbacks Don't Preserve Narrowing
function processLater(obj: { value: string | number }) {
if (typeof obj.value === 'number') {
setTimeout(() => {
// obj.value is string | number again!
console.log(obj.value.toFixed(2)); // Error!
});
}
}
TypeScript knows that obj.value might change before the callback runs. Capture the value in a local variable:
function processLater(obj: { value: string | number }) {
if (typeof obj.value === 'number') {
const value = obj.value; // Capture as number
setTimeout(() => {
console.log(value.toFixed(2)); // OK
});
}
}
Practical Example: Safe JSON Parsing
Here's how to combine type guards with narrowing for safe API responses:
interface ApiResponse<T> {
status: 'success' | 'error';
data?: T;
error?: string;
}
interface User {
id: number;
name: string;
}
function isSuccessResponse<T>(
response: ApiResponse<T>
): response is ApiResponse<T> & { status: 'success'; data: T } {
return response.status === 'success' && response.data !== undefined;
}
async function fetchUser(id: number): Promise<User | null> {
const response: ApiResponse<User> = await fetch(`/api/users/${id}`)
.then(r => r.json());
if (isSuccessResponse(response)) {
// response.data is User (guaranteed)
return response.data;
}
console.error(response.error);
return null;
}
Summary
- Type narrowing is TypeScript's ability to refine types based on control flow
- A variable can have different types at different locations in your code
- Use
typeof,instanceof,in, andArray.isArray()for narrowing - Tagged unions are the most powerful pattern for discriminating types
- User-defined type guards let you teach TypeScript about custom type checks
- Be aware of pitfalls:
typeof null, falsy values, and callbacks
Mastering type narrowing will help you write TypeScript code that's both type-safe and reads naturally. Let the compiler work for you!
References
- TypeScript Handbook: Narrowing
- Vanderkam, Dan. Effective TypeScript, 2nd Edition. O'Reilly Media, 2024.