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 |