Overlap-preserving type refinement assertion signatures in TypeScript

In the last post I wrote about how TypeScript assertion signatures are more useful when they can incorporate existing type information from the calling scope.

Sometimes the asserted type is less precise than information TypeScript already has in scope at the calling site.

function assertString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error("Expected string");
  }
}

type Foo = 
  | "draft"
  | "published"
  | "archived"
  | 404
  | null;

function getFoo(): Foo {
  return "draft";
}

const foo = getFoo();

assertString(foo);

foo;

// const foo: "draft" | "published" | "archived"

The assertion signature from assertString tells TypeScript that foo is a string, and TypeScript is able to combine that with other type information from the scope to infer that foo must therefore be of a literal union type "draft" | "published" | "archived".

That effect could be described as overlap-preserving type refinement. Assertion signatures can provide a lot more benefit if they allow it to happen.

The assertion and matcher functions in the @kensio/smartass library provide this overlapping refinement effect.

For example, here is a test case from the package for the assertString assertion function:

import { assertTypeString } from "@kensio/smartass";
import { expectTypeOf, expect } from "vitest";

interface Foo {
  bar?: { foobar?: "hello" | null };
}

function getFoo(): Foo {
  return { bar: { foobar: "hello" } };
}

const foo = getFoo();

assertTypeString(foo.bar?.foobar);

expectTypeOf(foo.bar.foobar).toExtend<string>();
expectTypeOf(foo.bar.foobar).toEqualTypeOf<"hello">();
expectTypeOf(foo.bar.foobar).not.toEqualTypeOf<string>();
expect(foo.bar.foobar).toBeTypeOf("string");
expect(foo.bar.foobar).toBe("hello");

This uses Vitest’s type testing feature to apply exact compile-time type checking in the test.

Note how the type of foo.bar.foobar is not just narrowed to string. At compile-time, the type of foo.bar.foobar is not exactly equal to string. It gets narrowed to a literal string "hello" type.

The composable matcher functions in the @kensio/smartass library also provide this overlapping type refinement effect.

Here is another test case for the typeString composable matcher function:

import { assertObjectMatches, typeString } from "@kensio/smartass";
import { expectTypeOf, expect } from "vitest";

interface Foo {
  bar?: {
    foobar?: "hello" | "world" | 123 | null;
  };
}

function getFoo(): Foo {
  return { bar: { foobar: "hello" } };
}

// Given an object property whose static type includes string literals
// and non-string alternatives.
const foo = getFoo();

// When the property is matched with the composable string matcher.
assertObjectMatches(foo, {
  bar: { foobar: typeString() },
});

// Then the property should keep the known string literal overlap instead
// of widening to the less precise string type.
expectTypeOf(foo.bar.foobar).toEqualTypeOf<"hello" | "world">();
expectTypeOf(foo.bar.foobar).toExtend<string>();
expectTypeOf(foo.bar.foobar).not.toEqualTypeOf<string>();
expectTypeOf(foo.bar.foobar).not.toEqualTypeOf<number>();
expectTypeOf(foo.bar.foobar).not.toEqualTypeOf<null>();
expect(foo.bar.foobar).toBeTypeOf("string");

The typeString() composable matcher works from within assertObjectMatches() to provide type refinement back to the calling scope. Again, TypeScript is able to combine the assertion that foo.bar.foobar must be of type string with the existing type information in the scope. It can exclude 123 | null as possibilities from the "hello" | "world" | 123 | null union type, leaving only "hello" | "world".

This overlap-preserving type refinement effect is useful in tests, because it combines run-time validation with compile-time type checking. It often makes it easier to write tests with less clutter, because the compiler can follow the types implied by the test assertions.


View post: Overlap-preserving type refinement assertion signatures in TypeScript