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

โ€Œ