TypeScript Narrowing

Photo by Numeroo77 on Unsplash

Note that some examples are extracted from the TypeScript Handbook, and all tributes should go to it.

Why need narrowing

function printId(id: number | string) {console.log(id.toUpperCase());
// Property 'toUpperCase' does not exist on type 'string | number'.

The solution is to narrow the union with code, the same as in JavaScript with if else statement. Narrowing occurs when TypeScript can deduce a more specific type for a value based on the context of the code:

function printId(id: number | string) {if (typeof id === "string") {
console.log(id.toUpperCase()); // id: string
} else {
console.log(id); // id: number
}
}

typeof type guards

"string", "number", "bigint", "boolean", "symbol", "undefined", "object", "function"

Note that the discrepancy between type definition in JS and TS can cause problem, e.g. in JavaScript, typeof null is actually "object".

Truthiness narrowing

function printAll(strs: string | string[] | null) {
if (strs && typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}

Equality narrowing

function example(x: string | number, y: string | boolean) {
if (x === y) {
x.toUpperCase();
y.toLowerCase();
// x, y : string
} else {
console.log(x);
// x: string | number
console.log(y);
// y: string | boolean
}
}

Note that == null actually also checks whether it’s undefined or null. The same applies to == undefined.

interface Container {
value: number | null | undefined;
}
function multiplyValue(container: Container, factor: number) {
// Remove both 'null' and 'undefined' from the type.
if (container.value != null) {
console.log(container.value);
}
}

instanceof narrowing

function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString());
// x: Date
} else {
console.log(x.toUpperCase());
// x: string
}
}

Assignments

Control flow analysis

function example() { let x: string | number | boolean; x = Math.random() < 0.5;
console.log(x);
// x: boolean
if (Math.random() < 0.5) {
x = "hello";
console.log(x);
// x: string
} else {
x = 100;
console.log(x);
// x: number
}
return x;
// x: string | number | boolean;
}

Type predicates

type Fish = { swim: () => void };
type Bird = { fly: () => void };
declare function getSmallPet(): Fish | Bird;function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}

pet is Fish is our type predicate in this example. A predicate takes the form parameterName is Type, where parameterName must be the name of a parameter from the current function signature.

Any time isFish is called with some variable, TypeScript will narrow that variable to that specific type if the original type is compatible.

// Both calls to 'swim' and 'fly' are now okay.
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}

Discriminated Union

  • Types that have a common type property — the discriminant.
  • A type alias that takes the union of those types — the union.
  • A type guard on the common property (on the discriminant)

If you have a class with a literal member then you can use that property to discriminate between union members. If you use a type guard style check (==, ===, !=, !==) or switch on the discriminant property.

For example, we want to calculate the area based on different objects:

interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
type Shape = Square | Rectangle | Circle;function area(s: Shape) {
if (s.kind === “square”) {
return s.size * s.size;
}
else if (s.kind === “rectangle”) {
return s.width * s.height;
}
else {
const _exhaustiveCheck: never = s;
//
Argument of type 'Circle' is not assignable to parameter of type 'never'.
}
}

Note the last fall through is to make sure that all types — including the unexpected ones (in our case, the Circle) have some action against them. (exhaustive check).

The shortened version using Switch statement:

function area(s: Shape) {
switch (s.kind) {
case “square”: return s.size * s.size;
case “rectangle”: return s.width * s.height;
case “circle”: return Math.PI * s.radius * s.radius;
default: const _exhaustiveCheck: never = s;
return _exhaustiveCheck;
}
}

We can convert the exhaustive check to use a function:

function assertNever(x:never): never {
throw new Error('Unexpected value. Should have been never.');
}
function area(s: Shape) {
switch (s.kind) {
case “square”: return s.size * s.size;
case “rectangle”: return s.width * s.height;
default: return assertNever(s);
}
}

That’s so much of it. Please refer to the Handbook for more details.

Happy Reading.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store