Type Narrowing in TypeScript

Jack Williams
6 min readJan 13, 2019

--

Dynamic checks and predicates gives us information about values at run-time; type narrowing is the process of reflecting this information in the type-checker at compile time. For example:

function numberFromUnknown(x: unknown): number {
if (typeof x === "number") {
return x + 10; // 'x' has type 'number'
}
return 0;
}

The variable x initially has the type unknown: the type of all values. The predicate typeof x === "number" extracts dynamic information about the value bound to x, but the type-checker can exploit this information for static reasoning. In the body of the if statement the predicate is assumed to be true; therefore, it must be the case the value bound to x is a number. TypeScript exploits this information and narrows the type of x from unknown to number in the body of if statement.

This article will explain some of the technical details of type narrowing in TypeScript, including different forms of type narrowing.

Type Information, Subtyping, and Narrowing

First we will review the intuition of type narrowing from the technical perspective of subtyping. The subtyping relation:

T <: U

relates two types, T and U, stating that T is a subtype of U. Informally, we may think of T as having more information than U, or that T is more precise than U. Examples include:

true <: boolean
1 <: number
number <: (number | string)
string <: unknown
{ x: number; y: number } <: { x: number }

Given a value x of type T, where T <: U, we are always permitted to use x at type U. Using x at type U is permitted because it only requires us to ‘forget’ type information we already had. For example, given a value x of literal type true (the type of the run-time value true), we can also view x as having type boolean because true <: boolean. All we must do is ‘forget’ that x had type true and instead view x at the less precise type boolean.

Moving in the reverse direction is, in general, not possible. Given a value x of type U, where T <: U, we cannot view x at type T because type T is more precise than U — we would be required to conjure type information from nothing. However, recall that dynamic checks and predicates are all about extracting information from values! Using the information extracted from dynamic predicates allows us to transition from a less precise type U, to a more precise type T — this is exactly what narrowing is about.

Key Forms of Narrowing

Narrowing is about moving from less precise types to more precise types; in TypeScript there are two primary ways this happens.

  • Narrowing by refinement
  • Narrowing by assertion

First, narrowing by refinement. In this instance a concrete type is refined to a more precise type. There are two actions that perform refinement: filtering and replacement. The action of filtering prunes obsolete branches from a union type. For example:

function filtering(x: number | boolean): number {
if (typeof x === "number") {
// 'x' has type 'number'
// 'boolean' is filtered from the union
return x + 10;
}
// 'x' has type 'boolean'
// 'number' is filtered from the union
return x ? 1 : 0;
}

The initial type of x is the union type number | boolean, that is, x is either a number or boolean. In the body of the if statement we know x must have type number, and consequently we may filter the type boolean from the union. After the if statement we know that x is not a number, otherwise control would have returned inside the if; here we may filter the type number from the union.

The action of replacement will substitute a less precise type for a more precise type, and this happens if we do not have a union type. For example:

function replacement(x: unknown): string {
if (typeof x === "string") {
// 'x' has type 'string'
// replace type 'unknown' with 'string'
return x + "\n";
}
// 'x' still has type 'unknown'
return "blank string";
}

The initial type of x is the type unknown, which cannot be directly filtered. Instead, when narrowing the type of x we replace the type with a more precise one. In the body of the if statement we know x must have type string, and consequently we replace the type of x with the type string. Note that we cannot use replacement to narrow x after the if because we do not have a concrete type to replace unknown with; at this point all we know is that x is not a string, which still leaves infinitely many possible types. To narrow x in this case would require negation types.

Filtering and replacement may be composed to narrow a type. For example:

function filterAndReplace(x: number | string): string {
if (x === "name") {
// 'x' has type '"name"'
// 'number' is filtered from the union
// 'string' is replaced with '"name"'
return x + "\n";
}
// 'x' still has type 'number | string'
return x.toString();
}

In this case we use a different predicate to narrow x: equality. In the body of the if we know that x is equal to the string "name". To narrow x we filter the type number from the union, because no number is equal to "name"; additionally, we also replace type string with the literal type "name". No narrowing is applied after the if statement because the information is insufficient. All we know is that x is not equal to "name"; this does not rule out the possibility that x is a number, or that x is some other string.

The second key form of narrowing is narrowing by assertion. In some cases we do not know enough about the type we wish to narrow to apply filtering or replacement; this commonly arises when using generics. For example:

function wrong<T>(x: T, f: (x: T) => number): number {
if (typeof x === "number") {
// Replacing type 'T' with 'number' would prevent
// the following function call.
return f(x as number); // Error when 'x' is 'number'.
}
// This is ok:
return f(x);
}

The type T represents an abstract and unknown type. In the body of the if statement we know that x is a number, but we cannot filter the original type T because it is a type parameter, not a union. Furthermore, we cannot replace the type T with number because that would lose information. Specifically, the type number is not a subtype of T, so we cannot recover T from number. If we used replacement then the function call f(x) inside the if would not type-check, even though the call type-checks outside the if!

Rather than refining the type, we add an assertion to the type that records the new information we have obtained. The assertion takes the form of an intersection type U & T. When narrowing a value x of type U, we may learn additional information represented by type T. We assert that x is of type U and of type T — or x has the intersection type U & T. For example:

function byAssertion<T>(x: T, f:(x: T) => number): number {
if (typeof x === "number") {
// 'x' has type 'T & number'
return f(x) + x;
}
// 'x' still has type 'T'
return f(x);
}

In the body of the if statement we learn that x is a number, but equally we do not want to forget that x is of generic type T; we narrow to the intersection type T & number, recording both these facts. The narrowing still allows us to apply function f to x, but it also allows us to add x to another number.

Summary

There are two key forms of narrowing: refinement and assertion. The former modifies the type using filtering and replacement actions; the latter adds information using intersection types. In both cases we transition from a supertype to a subtype, ensuring that narrowing can always be undone and we do not lose type information.

--

--