Type Narrowing and Type Guards
Narrowing is how the compiler refines a wide type, such as a union or
unknown, to a more specific one inside a branch of code. It follows control
flow: after a runtime check, the type in the branch matches what the check
proved.
A type guard is the check itself: typeof, instanceof, in, a comparison
or a function that returns value is Type. A discriminated union is a
union whose members share a literal field (kind, type, status); checking
that field narrows to one member.
Applies to: TypeScript 7.0.
Details
Built-in Guards
| Check | Narrows | Watch out |
|---|---|---|
typeof x === 'string' | primitives, function, object | typeof null is 'object': null stays in the type |
x instanceof Date | instances of a class | does not work for interfaces and types: they are erased |
'swim' in x | union members that have (or may have) a key | on an object adds the key with type unknown |
x === 'a', x === y | to the literal or the common type | — |
x != null | removes both null and undefined | !== null removes only null |
if (x) (truthiness) | removes null, undefined, false | also drops 0, '' and NaN at runtime |
Array.isArray(x) | to an array type | on unknown gives any[] |
assignment x = 'a' | to the type of the assigned value | — |
switch (x.kind) | each case to its union member | — |
Discriminated Unions
- Each member has a property with a literal type:
{ kind: 'circle' },{ kind: 'square' }. if (shape.kind === 'circle')orswitch (shape.kind)narrows the whole object, so the fields of that member become available.- It also works after destructuring:
const { kind } = shape. - An exhaustive check with
neverindefaultturns a forgotten member into a compile error (see Basic Types);x satisfies neveris the short form ofconst unreachable: never = x. - The common model for API states, reducer actions and component props:
{ status: 'success'; data: T } | { status: 'error'; error: Error }.
User-Defined Type Guards
- A type predicate: a function returning
value is Type. The compiler does not check its body; it trusts the result. A wrong predicate is as unsafe asas. - Since TypeScript 5.5 a predicate is inferred for simple functions without
a return type annotation:
(x) => x !== undefinedgets the predicatex is T. That is whyitems.filter((x) => x !== undefined)returnsT[]. - An assertion function returns
asserts value is Typeorasserts condition. It throws if the check fails, and after the call the type is narrowed for the rest of the scope. It must be called through a name with an explicit type: a function declaration, or aconstwith a type annotation.
When Narrowing Is Lost
- In callbacks, for a
letvariable or a parameter that is reassigned after the callback is created: the callback may run later, after the value changed. Aconstalways stays narrowed; since TypeScript 5.4 so does aletor a parameter that is not reassigned after the callback is created. - Through a function annotated as returning
boolean: the compiler does not look inside, soisString(x): booleannarrows nothing. Without the annotation a simple check gets an inferred predicate (TypeScript 5.5); a complex body does not. - For the object when a property is checked:
if (res.data !== null)narrowsres.data, but notresitself. The object is narrowed only when it is a union and the property is its discriminant.
Example
type Shape =
{ kind: 'circle'; radius: number } | { kind: 'square'; size: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2; // shape: { kind: 'circle'; … }
case 'square':
return shape.size ** 2;
default:
return shape satisfies never; // error if a new kind is added
}
}
function format(value: string | number | Date | null) {
if (value == null) return '—';
if (typeof value === 'string') return value.trim();
if (value instanceof Date) return value.toISOString();
return value.toFixed(2); // number
}
// typeof null is 'object'
function describe(value: object | string | null) {
if (typeof value === 'object') {
return value.toString(); // Error: 'value' is possibly 'null'.
}
return value;
}
Type predicates and assertion functions
interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'number' &&
'name' in value &&
typeof value.name === 'string'
);
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data');
}
return data; // narrowed to User
}
// Inferred type predicate
const ids = [1, undefined, 3].filter((id) => id !== undefined); // number[]
// Assertion function
function assertDefined<T>(
value: T,
message: string
): asserts value is NonNullable<T> {
if (value == null) throw new Error(message);
}
function getRoot() {
const root = document.getElementById('root'); // HTMLElement | null
assertDefined(root, 'No #root element');
return root; // HTMLElement
}
// Narrowing in a callback
function scheduleLog(value: string | null) {
if (value === null) return;
setTimeout(() => console.log(value.length)); // OK: value is never reassigned
}
Follow-up Questions
- What is the difference between a type guard and a type assertion? A guard is a
real runtime check, and the compiler narrows from it. An assertion (
as) checks nothing; see Type Assertions. - Why does
instanceofnot work with an interface? Interfaces are erased at compile time, andinstanceofneeds a constructor at runtime. Check a discriminant or a property withininstead. - How do you validate an API response? A type predicate written by hand, or a schema validator (Zod, Valibot) that both checks and infers the type.
- What is
satisfies neverindefault? An exhaustive check: if a member of the union is not handled, the remaining type is notneverand the line fails.
Common Mistakes
- Treating
typeof x === 'object'as "a non-null object". if (count)for a number:0is falsy, so a valid value goes to the wrong branch. Compare withnullorundefinedinstead.- Writing a predicate that does not match its check, for example
x is Userthat only checks'id' in x. The compiler trusts it. - Using optional fields instead of a discriminated union:
{ data?: T; error?: Error }allows both and neither.