TypeScript: narrowing types via type guards and assertion functions

[2020-06-07] dev, javascript, typescript
(Ad, please don’t block)

In TypeScript, a value can have a type that is too general for some operations – for example, a union type. This blog post answers the following questions:

  • What is narrowing of types?
    • (Spoiler: focusing on a subset of an overly general type, in a specific region of a program)
  • What are type guards and assertion functions and how can we use them to narrow types?

When are static types too general?  

To see how a static type can be too general, consider the following function getScore():

assert.equal(
  getScore('*****'), 5);
assert.equal(
  getScore(3), 3);

The skeleton of getScore() looks as follows:

function getScore(value: number|string): number {
  // ···
}

Inside the body of getScore(), we don’t know if the type of value number or string. Before we do, we can’t really work with value.

Narrowing via if  

The solution is to check the type of value at runtime, via typeof (line A and line B):

function getScore(value: number|string): number {
  if (typeof value === 'number') { // (A)
    // %inferred-type: number
    value;
    return value;
  }
  if (typeof value === 'string') { // (B)
    // %inferred-type: string
    value;
    return value.length;
  }
  throw new Error('Unsupported value: ' + value);
}

In this blog post, we interpret types as sets of values. (For more information on this interpretation and another one, see “What is a type in TypeScript? Two perspectives”.)

Inside the then-blocks starting in line A and line B, the static type of value changes, due to the checks we performed. We are now working with subsets of the original type number|string. This way of reducing the size of a type is called narrowing. Checking the result of typeof and similar runtime operations are called type guards.

Note that narrowing does not change the original type of value, it only makes it more specific as we pass more checks.

Narrowing via switch  

Narrowing also works if we use switch instead of if:

function getScore(value: number|string): number {
  switch (typeof value) {
    case 'number':
      // %inferred-type: number
      value;
      return value;
    case 'string':
      // %inferred-type: string
      value;
      return value.length;
    default:
      throw new Error('Unsupported value: ' + value);
  }
}

More cases of types being too general  

These are more examples of types being too general:

  • Nullable types:

    function func1(arg: null|string) {}
    function func2(arg: undefined|string) {}
    
  • Discriminated unions:

    type Teacher = { kind: 'Teacher', teacherId: string };
    type Student = { kind: 'Student', studentId: string };
    type Attendee = Teacher | Student;
    
    function func3(attendee: Attendee) {}
    
  • Types of optional parameters:

    function func4(arg?: string) {}
    

Note that these types are all union types!

The type unknown  

If a value has the type unknown, we can do almost nothing with it and have to narrow its type first (line A):

function parseStringLiteral(stringLiteral: string): string {
  const result: unknown = JSON.parse(stringLiteral);
  if (typeof result === 'string') { // (A)
    return result;
  }
  throw new Error('Not a string literal: ' + stringLiteral);
}

In other words: The type unknown is too general and we must narrow it. In a way, unknown is also a union type (the union of all types).

Narrowing via built-in type guards  

As we have seen, a type guard is an operation that returns either true or false – depending on whether its operand meets certain criteria at runtime. TypeScript’s type inference supports type guards by narrowing the static type of an operand when the result is true.

Strict equality (===)  

Strict equality works as a type guard:

function func(value: unknown) {
  if (value === 'abc') {
    // %inferred-type: "abc"
    value;
  }
}

We can also use === to differentiate between the components of a union type:

interface Book {
  title: null | string;
  isbn: string;
}

function getTitle(book: Book) {
  if (book.title !== null) {
    // %inferred-type: string
    book.title;
    return book.title;
  } else {
    // %inferred-type: null
    book.title;
    return '(Untitled)';
  }
}

typeof, instanceof, Array.isArray  

These are three common built-in type guards:

function func(value: Function|Date|number[]) {
  if (typeof value === 'function') {
    // %inferred-type: Function
    value;
  }

  if (value instanceof Date) {
    // %inferred-type: Date
    value;
  }

  if (Array.isArray(value)) {
    // %inferred-type: number[]
    value;
  }
}

Note how the static type of value is narrowed inside the then-blocks.

Checking for distinct properties via the operator in  

If used to check for distinct properties, the operator in is a type guard:

type FirstOrSecond =
  | {first: string}
  | {second: string};

function func(firstOrSecond: FirstOrSecond) {
  if ('second' in firstOrSecond) {
    // %inferred-type: { second: string; }
    firstOrSecond;
  }
}

Note that the following check would not have worked:

function func(firstOrSecond: FirstOrSecond) {
  // @ts-ignore: Property 'second' does not exist on
  // type 'FirstOrSecond'. [...]
  if (firstOrSecond.second !== undefined) {
    // ···
  }
}

The problem in this case is that, without narrowing, we can’t access property .second of a value whose type is FirstOrSecond.

The operator in doesn’t narrow non-union types  

Alas, in only helps us with union types:

function func(obj: object) {
  if ('name' in obj) {
    // %inferred-type: object
    obj;

    // @ts-ignore: Property 'name' does not exist on type 'object'.
    obj.name;
  }
}

Checking the value of a shared property (discriminated unions)  

In a discriminated union, the components of a union type have one or more properties in common whose values are different for each component. As a consequence, checking the value of one of those properties is a type guard:

type Teacher = { kind: 'Teacher', teacherId: string };
type Student = { kind: 'Student', studentId: string };
type Attendee = Teacher | Student;

function getId(attendee: Attendee) {
  switch (attendee.kind) {
    case 'Teacher':
      // %inferred-type: { kind: "Teacher"; teacherId: string; }
      attendee;
      return attendee.teacherId;
    case 'Student':
      // %inferred-type: { kind: "Student"; studentId: string; }
      attendee;
      return attendee.studentId;
    default:
      throw new Error();
  }
}

In the previous example, .kind is a so-called discriminating property: All components of the union type Attendee have it and it has a different value in each one.

An if statement and equality checks work similarly to a switch statement:

function getId(attendee: Attendee) {
  if (attendee.kind === 'Teacher') {
    // %inferred-type: { kind: "Teacher"; teacherId: string; }
    attendee;
    return attendee.teacherId;
  } else if (attendee.kind === 'Student') {
    // %inferred-type: { kind: "Student"; studentId: string; }
    attendee;
    return attendee.studentId;
  } else {
    throw new Error();
  }
}

Narrowing dotted names  

We can also narrow the types of properties (even of nested ones that we access via chains of property names):

type MyType = {
  prop?: number | string,
};
function func(arg: MyType) {
  if (typeof arg.prop === 'string') {
    // %inferred-type: string
    arg.prop; // (A)

    [].forEach((x) => {
      // %inferred-type: string | number | undefined
      arg.prop; // (B)
    });

    // %inferred-type: string
    arg.prop;

    arg = {};

    // %inferred-type: string | number | undefined
    arg.prop; // (C)
  }
}

Let’s take a look at several locations in the previous code:

  • Line A: We narrowed the type of arg.prop via a type guard.
  • Line B: Callbacks may be executed much later (think of asynchronous code), which is why TypeScript undoes narrowing inside callbacks.
  • Line C: The preceding assignment also undid narrowing.

Narrowing Array element types  

The Array method .every() does not narrow  

If we use .every() to check that all Array elements are non-nullish, TypeScript does not narrow the type of mixedValues (line A):

const mixedValues: ReadonlyArray<undefined|null|number> =
  [1, undefined, 2, null];

if (mixedValues.every(isNotNullish)) {
  // %inferred-type: readonly (number | null | undefined)[]
  mixedValues; // (A)
}

Note that mixedValues has to be read-only. If it weren’t, another reference to it would statically allow us to push null into mixedValues inside the if statement. But that renders the narrowed type of mixedValues incorrect.

The previous code uses the following user-defined type guard (more on what that is soon):

function isNotNullish<T>(value: T): value is NonNullable<T> { // (A)
  return value !== undefined && value !== null;
}

NonNullable<Union> (line A) is a utility type that removes the types undefined and null from union type Union.

The Array method .filter() produces Arrays with narrower types  

.filter() produces Arrays that have narrower types (i.e., it doesn’t really narrow existing types):

const mixedValues: ReadonlyArray<undefined|null|number> =
  [1, undefined, 2, null];

// %inferred-type: number[]
const numbers = mixedValues.filter(isNotNullish);

(isNotNullish() is defined in the previous subsection.)

Alas, even here, we must use a type guard function directly – an arrow function with a type guard is not enough:

// %inferred-type: (number | null | undefined)[]
const stillMixed1 = mixedValues.filter(
  x => x !== undefined && x !== null);

// %inferred-type: (number | null | undefined)[]
const stillMixed2 = mixedValues.filter(
  x => typeof x === 'number');

User-defined type guards  

TypeScript lets us define our own type guards – for example:

function isFunction(value: unknown): value is Function {
  return typeof value === 'function';
}

The return type value is Function is a type predicate. It is part of the type signature of isFunction():

// %inferred-type: (value: unknown) => value is Function
isFunction;

A user-defined type guard must always return booleans. If isFunction(x) returns true, TypeScript can narrow the type of the actual argument x to Function:

function func(arg: unknown) {
  if (isFunction(arg)) {
    // %inferred-type: Function
    arg; // type is narrowed
  }
}

Note that TypeScript doesn’t care how we compute the result of a user-defined type guard. That gives us a lot of freedom w.r.t. the checks we use. For example, we could have implemented isFunction() as follows:

function isFunction(value: any): value is Function {
  try {
    value(); // (A)
    return true;
  } catch {
    return false;
  }
}

Alas, the type unknown does not let us make the function call in line A. Therefore, we have to use the type any for the parameter value.

Example of a user-defined type guard: isArrayInstanceof()  

/**
 * This type guard for Arrays works similarly to `Array.isArray()`,
 * but also checks if all Array elements are instances of `T`.
 * As a consequence, the type of `arr` is narrowed to `Array<T>`
 * if this function returns `true`.
 * 
 * Warning: This type guard can produce unsafe code because we could
 * subsequently use an alias to push a value into `arr` whose type
 * isn’t T.
 */
function isArrayInstanceof<T>(arr: any, Class: new (...args: any[])=>T)
: arr is Array<T> {
  // %inferred-type: any
  arr;

  if (!Array.isArray(arr)) {
    return false;
  }

  // %inferred-type: any[]
  arr; // narrowed

  if (!arr.every(elem => elem instanceof Class)) {
    return false;
  }

  // %inferred-type: any[]
  arr; // not narrowed

  return true;
}

This is how this function is used:

const value: unknown = {};
if (isArrayInstanceof(value, RegExp)) {
  // %inferred-type: RegExp[]
  value;
}

Example of a user-defined type guard: isTypeof()  

A first attempt  

This is a first attempt to implement typeof in TypeScript:

/**
 * An implementation of the `typeof` operator.
 */
function isTypeof<T>(value: any, prim: T): value is T {
  if (prim === null) {
    return value === null;
  }
  return value !== null && (typeof prim) === (typeof value);
}

Because it is difficult to turn an expected result of typeof (such as 'boolean' or 'number') into a type T, we opted to specify T via an example value. This is not ideal, but it works:

const value: unknown = {};
if (isTypeof(value, 123)) {
  // %inferred-type: number
  value;
}

Using overloading  

A better solution is to use overloading (several cases are omitted):

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof(value: any, typeString: 'boolean'): value is boolean;
function isTypeof(value: any, typeString: 'number'): value is number;
function isTypeof(value: any, typeString: 'string'): value is string;
function isTypeof(value: any, typeString: string): boolean {
  return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'boolean')) {
  // %inferred-type: boolean
  value;
}

(This approach is an idea by Nick Fisher.)

Using an interface as a type map  

An alternative is to use an interface as a map from strings to types (several cases are omitted):

interface TypeMap {
  boolean: boolean;
  number: number;
  string: string;
}

/**
 * A partial implementation of the `typeof` operator.
 */
function isTypeof<T extends keyof TypeMap>(value: any, typeString: T)
: value is TypeMap[T] {
  return typeof value === typeString;
}

const value: unknown = {};
if (isTypeof(value, 'string')) {
  // %inferred-type: string
  value;
}

(This approach is an idea by Ran Lottem.)

Assertion functions  

An assertion function checks if its parameter fulfills certain criteria and throws an exception if it doesn’t. For example, one assertion function supported by many languages, is assert(). assert(cond) throws an exception if the boolean condition cond is false.

On Node.js, assert() is supported via the built-in module assert. The following code uses it in line A:

import assert from 'assert';
function removeFilenameExtension(filename: string) {
  const dotIndex = filename.lastIndexOf('.');
  assert(dotIndex >= 0); // (A)
  return filename.slice(0, dotIndex);
}

TypeScript’s support for assertion functions  

TypeScript’s type inference provides special support for assertion functions, if we mark such functions with assertion signatures as return types. W.r.t. how and what we can return from a function, an assertion signature is equivalent to void. However, it additionally triggers narrowing.

There are two kinds of assertion signatures:

  • Asserting a boolean argument: asserts «cond»
  • Asserting the type of an argument: asserts «arg» is «type»

Asserting a boolean argument: asserts «cond»  

In the following example, the assertion signature asserts condition states that the parameter condition must be true. Otherwise, an exception is thrown.

function assertTrue(condition: boolean): asserts condition {
  if (!condition) {
    throw new Error();
  }
}

This is how assertTrue() causes narrowing:

function func(value: unknown) {
  assertTrue(value instanceof Set); // (A)

  // %inferred-type: Set<any>
  value; // (B)
}

The invocation of the assertion function assertTrue() in line A influenced the static type of value in line B (in a manner similar to type guards).

Asserting the type of an argument: asserts «arg» is «type»  

In the following example, the assertion signature asserts value is number states that the parameter value must have the type number. Otherwise, an exception is thrown.

function assertIsNumber(value: any): asserts value is number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
}

This time, calling the assertion function, narrows the type of its argument:

function func(value: unknown) {
  assertIsNumber(value);

  // %inferred-type: number
  value;
}

Example assertion function: adding properties to an object  

The function addXY() adds properties to existing objects and updates their types accordingly:

function addXY<T>(obj: T, x: number, y: number)
: asserts obj is (T & { x: number, y: number }) {
  // Adding properties via = would be more complicated...
  Object.assign(obj, {x, y});
}

const obj = { color: 'green' };
addXY(obj, 9, 4);

// %inferred-type: { color: string; } & { x: number; y: number; }
obj;

An intersection type S & T produces a type that has the properties of both S and T.

Quick reference: user-defined type guards and assertion functions  

User-defined type guards  

function isString(value: unknown): value is string {
  return typeof value === 'string';
}
  • Type predicate: value is string
  • Result: boolean

Assertion functions  

Assertion signature: asserts «cond»  

function assertTrue(condition: boolean): asserts condition {
  if (!condition) {
    throw new Error(); // assertion error
  }
}
  • Assertion signature: asserts condition
  • Result: void, exception

Assertion signature: asserts «arg» is «type»  

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(); // assertion error
  }
}
  • Assertion signature: asserts value is string
  • Result: void, exception

Alternatives to assertion functions  

Technique: forced conversion  

An assertion function narrows the type of an existing value. A forced conversion function returns an existing value with a new type – for example:

function forceNumber(value: unknown): number {
  if (typeof value === 'number') {
    return value;
  }
  throw new TypeError();
}

const value1a: unknown = 123;
// %inferred-type: number
const value1b = forceNumber(value1a);

const value2: unknown = 'abc';
assert.throws(() => forceNumber(value2));

The corresponding assertion function looks as follows:

function assertIsNumber(value: unknown): asserts value is number {
  if (typeof value !== 'number') {
    throw new TypeError();
  }
}

const value1: unknown = 123;
assertIsNumber(value1);
// %inferred-type: number
value1;

const value2: unknown = 'abc';
assert.throws(() => assertIsNumber(value2));

Forced conversion is a versatile technique with uses beyond being an alternative to assertion functions. For example, you can convert an input format (think JSON schema) that is easy to write into an output format that is easy to work with in code.

Technique: throwing an exception  

Consider the following code:

function getLengthOfValue(strMap: Map<string, string>, key: string)
: number {
  if (strMap.has(key)) {
    const value = strMap.get(key);

    // %inferred-type: string | undefined
    value; // before throwing an exception

    // We know that value can’t be `undefined`
    if (value === undefined) { // (A)
      throw new Error();
    }

    // %inferred-type: string
    value; // after throwing an exception

    return value.length;
  }
  return -1;
}

Instead of the if statement that starts in line A, we also could have used an assertion function:

assertNotUndefined(value);

Throwing an exception is a quick alternative if we don’t want to write such a function. Similarly to calling an assertion function, this technique also updates the static type.

@hqoss/guards: library with type guards  

The library @hqoss/guards provides a collection of type guards for TypeScript – for example:

  • Primitives: isBoolean(), isNumber(), etc.
  • Specific types: isObject(), isNull(), isFunction(), etc.
  • Various checks: isNonEmptyArray(), isInteger(), etc.