DEV Community

Chris Cook
Chris Cook

Posted on • Originally published at zirkelc.dev

Assertions: How to Assert Conditions and Types

The asserts statement was introduced in TypeScript 3.7. It's a special type of function signature that tells the TypeScript compiler that a particular condition is true from that point on. Essentially, assertions serve as macros for if-then-error statements, allowing us to encapsulate precondition checks at the beginning of function blocks, enhancing the predictability and stability of our code.

Basic Assertions

Consider a basic assertion that checks for a truthy condition. Pay attention to the return type of the function.

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}
Enter fullscreen mode Exit fullscreen mode

The asserts condition return type within this function signals to TypeScript that, given the function's successful execution, the provided condition is true. Otherwise, an error will be thrown with the specified message.

Here's how this assert function can be used to check unknown parameters:

type Point = { 
  x: number; 
  y: number 
};

function point(x: unknown, y: unknown): Point {
  assert(typeof x === 'number', 'x is not a number');
  assert(typeof y === 'number', 'y is not a number');

  //> from here on, we know that `x` and `y` are numbers
  return { x, y };
}
Enter fullscreen mode Exit fullscreen mode

TypeScript evaluates the condition typeof x === number and infers the appropriate type for the parameters. After the assert calls, TypeScript is aware that x and y are numbers.

Asserting Specific Types

Beyond asserting a condition, the asserts keyword can validate that a variable matches a specific type. This is achieved by appending a type guard after asserts.

Consider the following example:

function assertPoint(val: unknown): asserts val is Point {
  if (typeof val === 'object' && 'x' in val && 'y' in val && typeof val.x === 'number' && typeof val.y === 'number') {
    return;
  }

  throw new Error('val is not a Point');
}
Enter fullscreen mode Exit fullscreen mode

If the assertPoint function executes without errors, TypeScript assumes that val is a Point. This knowledge is retained throughout the block, as demonstrated in this function:

function print(point: unknown) {
  assertPoint(point);

  //> from here on, we know that `p` is a Point
  console.log(`Position X=${point.x} Y={point.y}`);
}
Enter fullscreen mode Exit fullscreen mode

Asserting Complex Types

The asserts isn't confined to simple types or distinct conditions. It also enables us to assert more intricate types. One such example is ensuring a value is defined using TypeScript's NonNullable<T> utility type.

Let's consider the following example:

function assertNonNull<T>(val: T): asserts val is NonNullable<T> {
  if (val === undefined || val === null) {
    throw new Error(`val is ${val === undefined ? 'undefined' : 'null'}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, the assertNonNull function verifies that the supplied value is neither null nor undefined. The return type asserts val is NonNullable<T> signals to TypeScript that if the function successfully executes, val has a defined value.

Lastly, this example demonstrates how this assertion can be paired with the prior one to check multiple conditions:

function move(point?: unknown) {
  assertNonNull(point);
  assertPoint(point);

  // > from here on, we know that `point` is defined and is a Point
  console.log(`Moving to ${point.x}, ${point.y}`);
}

Enter fullscreen mode Exit fullscreen mode

Here, the two assertions at the beginning of the function help TypeScript to gain knowledge about the nature of the given parameter. After these conditions, TypeScript knows that point is defined and it's an object of type Point.

If you're intrigued by assertions and wish to learn more, I recommend exploring the GitHub PR that brought assertions into TypeScript. For a quick hands-on experience, head over to the Playground from Microsoft.

Assertions in control flow analysis #32695

With this PR we reflect the effects of calls to assert(...) functions and never-returning functions in control flow analysis. We also improve analysis of the effects of exhaustive switch statements, and report unreachable code errors for statements that follow calls to never-returning functions or exhaustive switch statements that return or throw in all cases.

The PR introduces a new asserts modifier that can be used in type predicates:

declare function assert(value: unknown): asserts value;
declare function assertIsArrayOfStrings(obj: unknown): asserts obj is string[];
declare function assertNonNull<T>(obj: T): asserts obj is NonNullable<T>;
Enter fullscreen mode Exit fullscreen mode

An asserts return type predicate indicates that the function returns only when the assertion holds and otherwise throws an exception. Specifically, the assert x form indicates that the function returns only when x is truthy, and the assert x is T form indicates that the function returns only when x is of type T. An asserts return type predicate implies that the returned value is of type void, and there is no provision for returning values of other types.

The effects of calls to functions with asserts type predicates are reflected in control flow analysis. For example:

function f1(x: unknown) {
    assert(typeof x === "string");
    return x.length;  // x has type string here
}

function f2(x: unknown) {
    assertIsArrayOfStrings(x);
    return x[0].length;  // x has type string[] here
}

function f3(x: string | undefined) {
    assertNonNull(x);
    return x.length;  // x has type string here
}
Enter fullscreen mode Exit fullscreen mode

From a control flow analysis perspective, a call to a function with an asserts x return type is equivalent to an if statement that throws when x is falsy. For example, the control flow of f1 above is analyzed equivalently to

function f1(x: unknown) {
    if (!(typeof x === "string")) {
        throw ...;
    }
    return x.length;  // x has type string here
}
Enter fullscreen mode Exit fullscreen mode

Similarly, a call to a function with an asserts x is T return type is equivalent to an if statement that throws when a call to a function with an x is T return type returns false. In other words, given

declare function isArrayOfStrings(obj: unknown): obj is string[];
Enter fullscreen mode Exit fullscreen mode

the control flow of f2 above is analyzed equivalently to

function f2(x: unknown) {
    if (!isArrayOfStrings(x)) {
        throw ...;
    }
    return x[0].length;  // x has type string[] here
}
Enter fullscreen mode Exit fullscreen mode

Effectively, assertIsArrayOfStrings(x) is just shorthand for assert(isArrayOfStrings(x)).

In addition to support for asserts, we now reflect effects of calls to never-returning functions in control flow analysis.

function fail(message?: string): never {
    throw new Error(message);
}

function f3(x: string | undefined) {
    if (x === undefined) fail("undefined argument");
    x.length;  // Type narrowed to string
}

function f4(x: number): number {
    if (x >= 0) return x;
    fail("negative number");
}

function f5(x: number): number {
    if (x >= 0) return x;
    fail("negative number");
    x;  // Unreachable code error
}
Enter fullscreen mode Exit fullscreen mode

Note that f4 is considered to not have an implicit return that contributes undefined to the return value. Without the call to fail an error would have been reported.

A function call is analyzed as an assertion call or never-returning call when

  • the call occurs as a top-level expression statement, and
  • the call specifies a single identifier or a dotted sequence of identifiers for the function name, and
  • each identifier in the function name references an entity with an explicit type, and
  • the function name resolves to a function type with an asserts return type or an explicit never return type annotation.

An entity is considered to have an explicit type when it is declared as a function, method, class or namespace, or as a variable, parameter or property with an explicit type annotation. (This particular rule exists so that control flow analysis of potential assertion calls doesn't circularly trigger further analysis.)

EDIT: Updated to include effects of calls to never-returning functions.

Fixes #8655. Fixes #11572. Fixes #12668. Fixes #13241. Fixes #18362. Fixes #20409. Fixes #20823. Fixes #22470. Fixes #27909. Fixes #27388. Fixes #30000.


I hope you found this post helpful. If you have any questions or comments, feel free to leave them below. If you'd like to connect with me, you can find me on LinkedIn or GitHub. Thanks for reading!

Top comments (1)

Collapse
 
niklampe profile image
Nik

This is really amazing. Love how you always produce such high quality content!