Type Narrowing

Similar to type widening, type narrowing is a related concept that demonstrates TypeScript’s pragmatism and usefulness. Read the code below and see if you can identify any issues.

function padLeft(padding: number | string, input: string) {
 
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
 
  return padding + input;
}

In the above TypeScript function, you may expect the code inside the if throw an error because " ".repeat(count: number) expects a mumber type as its parameter; however, the variable padding is of a union type number|string. How does it work? Under the hood of this seemingly uninteresting example, a lot of magic is happening. TypeScript uses specific checks for various code execution paths (if/else, for, in) and makes intelligent type inference decisions. It recognises the typeof operator as one of the type guards, and is confident that after the typeof padding === "number" check, the variable will indeed be of number type, allowing the code to pass the check.

const assertions

Apart from the no-widening literal types demonstrated in the Type Widening example, TypeScript 3.4 introduced a new construct for literal values called const assertions. It uses a type assertion in place of the type name. For example:

// Type '"hello"', no literal widening from "hello" to "string"
let x = "hello" as const;
 
// Type 'readonly [10, 20]', turning Array into a tuple
let y = [10, 20] as const;
 
// Type '{ readonly text: "hello" }', object literal gets readonly properties
let z = { text: "hello" } as const;
 
// It is possible to use angle bracket assertion syntax 
// outside of `.tsx` files, 
 
// Type '"hello"
let x = <const>"hello";
 
// Type 'readonly [10, 20]'
let y = <const>[10, 20];
 
// Type '{ readonly text: "hello" }'
let z = <const>{ text: "hello" };   `

This feature helps TypeScript to narrow types without explicit annotations.

// Works with no types referenced or declared.
// We only needed a single const assertion.
 
function getShapes() {
  let result = [
    { kind: "circle", radius: 100 },
    { kind: "square", sideLength: 50 },
  ] as const;
 
  return result;
}
 
for (const shape of getShapes()) {
  // Narrows perfectly!
  if (shape.kind === "circle") {
    console.log("Circle radius", shape.radius);
  } else {
    console.log("Square side length", shape.sideLength);
  }
}

Note that const assertion can only be applied immediately after a single type literal. It won’t work with expressions:

// Error! A 'const' assertion can only be applied to a
// to a string, number, boolean, array, or object literal.
let a = (Math.random() < 0.5 ? 0 : 1) as const;
let b = (60 * 60 * 1000) as const;
 
// Works!
let c = Math.random() < 0.5 ? (0 as const) : (1 as const);
let d = 3_600_000 as const;

Similar to declaring a variable with const, it only makes the variable un-reassignable and does not make it immutable.

let arr = [1, 2, 3, 4];
let foo = {
  name: "foo",
  contents: arr,
} as const;
 
foo.name = "bar"; // error!
foo.contents = []; // error!
foo.contents.push(5); // ...works!

Flow-based type inference

TypeScript uses flow-based type inference, which means TS will ‘read’ the code flow as humans would and reason the code based on flow control and operators such as if/else, switch, in, typeof || etc., to refine the types as it walks down through the code. Flow-based type inference also exists in Flow, Kotlin, and Ceylon. It refines types within a block of code, while many static-typed languages, such as C++ and Java, use symbols to determine the types of variables.

The blow example demonstrates how TS narrows (refines) the type of the width variable as it ‘reads’ the code:

// We use a union of string literals to describe
// the possible values a CSS unit can have
type Unit = 'cm' | 'px' | '%';
 
// Enumerate the units
const units: Unit[] = ['cm', 'px', '%'];
 
// Check the unit, and return if there is no match
function parseUnit(value: string): Unit | null {
  for (let i = 0; i < units.length; i++) {
    if (value.endsWith(units[i])) {
      return units[i];
    }
  }
  return null;
}
 
type Width {
  unit: Unit,
  value: number;
} // Representing a CSS width type
 
  
function parseWidth(width: number | string | null | undefined ): Width | null {
  // If width is null or undefined, return early
  if (width == null) {
    return null;
  }
  
  // If width is a number, default to pixels
  if (typeof width === 'number') {
    // TS: (parameter) width: string | number
    // TypeScript has now refined the type of `width` to `string | number`,
    // as if knows that `width` can not be a `null` anymore 😳
    return { unit: 'px', value: width };
  }
 
  // Try to parse a unit from a width
  let unit = parseUnit(width);
  // TS: (parameter) width: string
  // TS believes that `width` can only be a string now
  if (unit) {
    return { unit, value: parseFloat(width) };
  }
 
  // Otherwise, return `null`
  return null;
}

Discriminated Union Types

Flow-based type inference can narrow simple union types effectively. However, when there are chances of overlap of properties among union type members (by definition of union, the new type can ‘pick-and-choose’ from any of properties from any of the union type members), TypeScript must check each property of the type in question to narrow it down to a known union member safely.

A common solution is to use a discriminated union types, also called sum types in other languages. You turn a regular union type into a discriminated union types by include a property of string literal tpye in all members of the union type, such as state or type.

type UserTextEvent = {
  type: 'TextEvent', 
  value: string, 
  target: HTMLInputElement
} 
 
type UserMouseEvent = {
  type: 'MouseEvent', 
  value: [number, number],
  target: HTMLElement
}
 
type UserEvent = UserTextEvent | UserMouseEvent
 
function handle(event: UserEvent) {
  if (event.type === 'TextEvent') {
    event.value  // string     
    event.target // HTMLInputElement, 
    // TS can safely assume that its a UserTextEvent because of the check
    // on the common property `type`
    // ...
    return;
  }
  
  event.value    // [number, number]   event.target   // HTMLElement 
}

References