Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)

22 Type guards and assertion functions



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

22.1 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.

22.1.1 Narrowing via if and type guards

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 chapter, we interpret types as sets of values. (For more information on this interpretation and another one, see [content not included].)

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.

22.1.2 Narrowing via switch and a type guard

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);
  }
}

22.1.3 More cases of types being too general

These are more examples of types being too general:

Note that these types are all union types!

22.1.4 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).

22.2 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.

22.2.1 Strict equality (===)

Strict equality works as a type guard:

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

For some union types, we can use === to differentiate between their components:

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

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

Using === for including and !=== for excluding a union type component only works if that component is a singleton type (a set with one member). The type null is a singleton type. Its only member is the value null.

22.2.2 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.

22.2.3 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-expect-error: 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.

22.2.3.1 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-expect-error: Property 'name' does not exist on type 'object'.
    obj.name;
  }
}

22.2.4 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. Such properties are called discriminants.

Checking the value of a discriminant 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 discriminant: Each components of the union type Attendee has this property, with a unique value.

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();
  }
}

22.2.5 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:

22.2.6 Narrowing Array element types

22.2.6.1 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.

22.2.6.2 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):

// %inferred-type: (number | null | undefined)[]
const mixedValues = [1, undefined, 2, null];

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

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

Alas, 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');

22.3 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 narrows 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, we have to use the type any for the parameter value because the type unknown does not let us make the function call in line A.

22.3.1 Example of a user-defined type guard: isArrayWithInstancesOf()

/**
 * 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 make code unsafe – for example:
 * We could use another reference to `arr` to add an element whose
 * type is not `T`. Then `arr` doesn’t have the type `Array<T>`
 * anymore.
 */
function isArrayWithInstancesOf<T>(
  arr: any, Class: new (...args: any[])=>T)
  : arr is Array<T>
{
  if (!Array.isArray(arr)) {
    return false;
  }
  if (!arr.every(elem => elem instanceof Class)) {
    return false;
  }

  // %inferred-type: any[]
  arr; // (A)

  return true;
}

In line A, we can see that the inferred type of arr is not Array<T>, but our checks have ensured that it currently is. That’s why we can return true. TypeScript trusts us and narrows to Array<T> when we use isArrayWithInstancesOf():

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

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

22.3.2.1 A first attempt

This is a first attempt to implement typeof in TypeScript:

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

Ideally, we’d be able to specify the expected type of value via a string (i.e., one of the results of typeof). But then we would have to derive the type T from that string and it’s not immediately obvious how to do that (there is a way, as we’ll see soon). As a workaround, we specify T via a member prim of T:

const value: unknown = {};
if (isTypeof(value, 123)) {
  // %inferred-type: number
  value;
}
22.3.2.2 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.)

22.3.2.3 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.)

22.4 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);
}

22.4.1 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:

22.4.2 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);

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

We are using the argument value instanceof Set similarly to a type guard, but instead of skipping part of a conditional statement, false triggers an exception.

22.4.3 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;
}
22.4.3.1 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 has the properties of both type S and type T.

22.5 Quick reference: user-defined type guards and assertion functions

22.5.1 User-defined type guards

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

22.5.2 Assertion functions

22.5.2.1 Assertion signature: asserts «cond»
function assertTrue(condition: boolean): asserts condition {
  if (!condition) {
    throw new Error(); // assertion error
  }
}
22.5.2.2 Assertion signature: asserts «arg» is «type»
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(); // assertion error
  }
}

22.6 Alternatives to assertion functions

22.6.1 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') {
    throw new TypeError();
  }
  return value;
}

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 those of assertion functions. For example, we can convert:

For more information, see [content not included].

22.6.2 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 type check

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

    // %inferred-type: string
    value; // after type check

    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.

22.7 @hqoss/guards: library with type guards

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