bastianplsfix

Testing types

A type-level test is a compile error you arranged on purpose. In Deno you write it with assertType and IsExact from @std/testing/types, and it runs whenever something type-checks the file, which makes deno test cover both levels at once. This entry is about the helpers, the tricks they are built from, and the one property with no counterpart at the program level: a type-level test is not code, so when nothing checks it, it does not fail, it vanishes.

The helpers live in a second standard-library package, so add it once with deno add jsr:@std/testing. Then create programs/testing-types.test.ts for this reference and keep it open, starting with both imports:

import {
assert,
assertEquals,
assertStrictEquals,
assertThrows,
} from "@std/assert";
import {
assertType,
type Has,
type IsAny,
type IsExact,
type IsNever,
type IsUnknown,
} from "@std/testing/types";

Below the imports, add the function and type helpers the whole page shares; each earns its explanation in its step, including the assertion that is deliberately not inside a test.

function widen(value: string): string {
return value;
}

type MutuallyAssignable<A, B> = [A] extends [B] ? [B] extends [A] ? true
: false
: false;

type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true : false;

const OutputFormat = {
Json: "json",
Text: "text",
} as const;

const OUTPUT_FORMAT_KEYS = ["Json", "Text"] as const;

assertType<
IsExact<
(typeof OUTPUT_FORMAT_KEYS)[number],
keyof typeof OutputFormat
>
>(true);

Follow the page as you add and revise the runnable examples below it.

one assertion per level

Deno.test("one assertion per level", () => {
const result = widen("oak");

assertEquals(result, "oak");
assertType<IsExact<typeof result, string>>(true);

assertThrows(() => JSON.parse("{"), SyntaxError);

// @ts-expect-error: Argument of type 'number' is not assignable to parameter of type 'string'.
widen(123);
});
Check programs/testing-types.test.ts
running 1 test from ./programs/testing-types.test.ts
one assertion per level ... ok (453µs)

ok | 1 passed | 0 failed (3ms)

assertEquals checks the value that came back; assertType<IsExact<...>> checks the type that came back. The second line does nothing at run time, because assertType is a function with an empty body, and all the work happens in the type argument. The same pairing covers failures: assertThrows for something that must fail at run time, the pattern from the errors and exceptions page, and a @ts-expect-error shield for something that must fail at compile time. Both are assertions, and both fail the build when the thing they expect stops happening.

an annotation says assignable to, and IsExact says exactly

Deno.test("an annotation says assignable to, and IsExact says exactly", () => {
const narrow = "oak";

// An annotation says "assignable to", which is usually what you meant.
const widened: string = narrow;

// It cannot say "and nothing narrower", which is what IsExact is for.
assertType<IsExact<typeof narrow, "oak">>(true);
assertType<IsExact<typeof widened, string>>(true);
assertType<IsExact<typeof narrow, string>>(false);

assertEquals(narrow, widened);
});
an annotation says assignable to, and IsExact says exactly ... ok (75µs)

Most of the time you do not need a helper at all, because an annotation is already a test: const widened: string = narrow fails the build the moment narrow stops being assignable to string. What an annotation cannot say is "and nothing narrower", which is the whole reason IsExact exists: narrow is assignable to string, so only an identity check can tell you its type is actually "oak". Note the last assertion passes false: assertType takes the expected answer, so you can assert that two types are not the same without a negation helper.

a type-level test vanishes when nothing checks it

This is the part with no counterpart at the program level, and it is the reason the entry exists. Write the claim unshielded, knowing it is wrong:

Deno.test("a type-level test vanishes when nothing checks it", () => {
const answer = 42;

assertType<IsExact<typeof answer, string>>(true);

assertEquals(answer, 42);
});
Check programs/testing-types.test.ts
TS2345 [ERROR]: Argument of type 'true' is not assignable to parameter of type 'false'.
assertType<IsExact<typeof answer, string>>(true);
~~~~
at file:///programs/testing-types.test.ts:70:46

error: Type checking failed.

deno check reports it, and deno test reports the same thing, because the test runner type-checks by default and stops before running anything. Now run the same file with the flag that skips that step:

running 3 tests from ./programs/testing-types.test.ts
one assertion per level ... ok (402µs)
an annotation says assignable to, and IsExact says exactly ... ok (21µs)
a type-level test vanishes when nothing checks it ... ok (11µs)

ok | 3 passed | 0 failed (1ms)

3 passed | 0 failed, with a false type-level assertion sitting in the file. Not skipped, not reported, not counted, because the assertion only exists while something is checking types, and deno test --no-check never does. A program-level test that stops running fails loudly; a type-level test that stops being checked reports success. So the rule is short: keep type assertions in files that deno test covers, and never pass --no-check, because the flag that makes a run start faster turns every type-level test in the project into a comment. Shield the line to make the file's version deliberate:

Deno.test("a type-level test vanishes when nothing checks it", () => {
const answer = 42;

// @ts-expect-error: the type is 42, and this claims it is a string
assertType<IsExact<typeof answer, string>>(true);

assertEquals(answer, 42);
});
a type-level test vanishes when nothing checks it ... ok (14µs)

mutual assignability calls read-only types equal

Deno.test("mutual assignability calls read-only types equal", () => {
assertType<
IsExact<MutuallyAssignable<{ readonly a: 1 }, { a: 1 }>, true>
>(true);
assertType<IsExact<{ readonly a: 1 }, { a: 1 }>>(false);

const locked: { readonly a: 1 } = { a: 1 };
assertEquals(locked.a, 1);
});
mutual assignability calls read-only types equal ... ok (14µs)

The obvious definition of "the same type" is "assignable in both directions", which is what the module-scope MutuallyAssignable spells out, and it is wrong in one specific way. readonly does not affect assignability, the gap the read-only page measured, so mutual assignability calls {readonly a: 1} and {a: 1} equal. IsExact does not. If you are testing a type that adds or removes readonly, the kind the mapped types page builds with modifiers, this is the difference that matters, and it is why the helper cannot be built out of extends alone.

the trick that can tell them apart

Deno.test("the trick that can tell them apart", () => {
assertType<Identical<{ readonly a: 1 }, { a: 1 }>>(false);
assertType<Identical<string, string>>(true);
assertType<IsExact<Identical<"oak", string>, false>>(true);

const frozen = { a: 1 } as const;
assertType<Identical<typeof frozen, { readonly a: 1 }>>(true);
assertEquals(frozen.a, 1);
});
the trick that can tell them apart ... ok (18µs)

Identical is the module-scope two-liner: two generic function types are compared, and the checker only calls them compatible if the types in their bodies are identical rather than merely compatible. It is a hack that relies on an internal comparison being stricter than the public one, it has been the state of the art for years, and it is what IsExact is built on. The as const object confirms it from the value side, since as const is what puts the readonly there, as the read-only page shows.

a naive predicate is ruined by distribution

Deno.test("a naive predicate is ruined by distribution", () => {
type IsStringNaive<T> = T extends string ? true : false;
type IsStringWhole<T> = [T] extends [string] ? true : false;

assertType<IsExact<IsStringNaive<string | number>, boolean>>(true);
assertType<IsExact<IsStringWhole<string | number>, false>>(true);

assertType<
IsExact<MutuallyAssignable<"a", "a" | "b">, false>
>(true);

const bothAnswers: IsStringNaive<string | number> = true;
const alsoBoth: IsStringNaive<string | number> = false;

assertEquals(bothAnswers, true);
assertEquals(alsoBoth, false);
});
a naive predicate is ruined by distribution ... ok (16µs)

The last two assignments are the demonstration: both true and false are legal values of IsStringNaive<string | number>, because a bare T extends string ran once per union member and unioned the results. A predicate that returns boolean has told you nothing, a mechanism the conditional types page covers in full. This is why MutuallyAssignable has brackets around every operand: without them it would distribute and report boolean for any union argument, which is the first bug everybody writes when they build one of these by hand. The same applies to a Not<T> helper, if you write one: [T] extends [true], not T extends true.

any is equal to everything unless you check for it

Deno.test("any is equal to everything unless you check for it", () => {
// deno-lint-ignore no-explicit-any
assertType<IsExact<MutuallyAssignable<any, 123>, true>>(true);
// deno-lint-ignore no-explicit-any
assertType<IsExact<any, 123>>(false);

type IsAnyByTuple<T> = [T, 2] extends [1, T] ? true : false;
type IsAnyByIntersection<T> = 0 extends (1 & T) ? true : false;

// deno-lint-ignore no-explicit-any
assertType<IsExact<IsAnyByTuple<any>, true>>(true);
assertType<IsExact<IsAnyByTuple<unknown>, false>>(true);
assertType<IsExact<IsAnyByTuple<never>, false>>(true);
assertType<IsExact<IsAnyByTuple<1>, false>>(true);

// deno-lint-ignore no-explicit-any
assertType<IsExact<IsAnyByIntersection<any>, true>>(true);
assertType<IsExact<IsAnyByIntersection<unknown>, false>>(true);

// deno-lint-ignore no-explicit-any
assertType<IsExact<1 & any, any>>(true);
});
any is equal to everything unless you check for it ... ok (14µs)

any is assignable to and from everything, so mutual assignability says it equals 123, and 456, and string. That makes a hand-written equality helper useless in exactly the situation you most want one: checking that a type is not accidentally any. Detecting it with nothing but extends takes a trick, and there are two. The tuple asks two questions at once: T assignable to 1 narrows the candidates to 1, never, and any, then 2 assignable to T rules out 1 and never, and only any survives both. The intersection one is shorter and explains something: for any ordinary T, 1 & T is 1 or smaller, so 0 extends 1 & T is false, but 1 & any is any, and everything extends any. The last assertion pins that: any swallows an intersection, the same property that makes it dangerous generally, which the any, unknown, and never page is the entry for.

the standard library has the detectors already

Deno.test("the standard library has the detectors already", () => {
// deno-lint-ignore no-explicit-any
assertType<IsAny<any>>(true);
assertType<IsAny<unknown>>(false);
assertType<IsNever<never>>(true);
assertType<IsNever<undefined>>(false);
assertType<IsUnknown<unknown>>(true);
// deno-lint-ignore no-explicit-any
assertType<IsUnknown<any>>(false);
});
the standard library has the detectors already ... ok (13µs)

You do not have to write either trick: IsAny, IsNever, and IsUnknown are all in @std/testing/types, and each is built from a trick like the ones above. Testing the three top and bottom types separately matters because they are the three that break every other check.

Has asks the other question

Deno.test("Has asks the other question", () => {
type Size = "small" | "medium" | "large";

assertType<Has<Size, string>>(true);
assertType<Has<"medium", Size>>(true);
assertType<Has<Size, "medium">>(true);
assertType<Has<Size, number>>(false);

assertType<IsExact<Size, "medium">>(false);

assertType<Has<keyof Array<string>, "push">>(true);
assert("push" in Array.prototype);
});
Has asks the other question ... ok (20µs)

Has<T, U> is Extract<T, U> extends never ? false : true, so it asks whether any member of T is assignable to U. It is the assignability check to IsExact's identity check, and a test suite wants both: IsExact for "this is the type", Has for "this is among the legal ones". It is also the only tool for a union too large to write out. keyof Array<string> includes number, "length", and every array method, a shape the keyof and indexed access page returns to; Has<keyof Array<string>, "push"> is a claim you can actually make about it, and the run-time line beside it checks the same fact from the other side.

a shield checks that there is an error, not which one

The comment on a @ts-expect-error is not compared to anything. Predict what the shielded line leaves behind at run time, trusting the annotation:

Deno.test("a shield checks that there is an error, not which one", () => {
// @ts-expect-error: this text is a comment, and nothing compares it to the error
const wrong: number = "oak";

assertEquals(typeof wrong, "number");
});
Check programs/testing-types.test.ts
running 10 tests from ./programs/testing-types.test.ts
...
a shield checks that there is an error, not which one ... FAILED (14ms)

ERRORS

a shield checks that there is an error, not which one => ./programs/testing-types.test.ts:162:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- string
+ number

FAILURES

a shield checks that there is an error, not which one => ./programs/testing-types.test.ts:162:6

FAILED | 9 passed | 1 failed (16ms)

error: Test failed

The annotation lies. @ts-expect-error suppresses whatever error is on the next line, and it does not care which, so the shield happily let a string into a variable annotated number, and typeof wrong is "string" at run time. The text after the directive is a comment: nothing in the toolchain compares it to the error, so it can be out of date or simply wrong and everything still passes, which is the weak part of the directive. Correct the prediction and pin what actually happened:

Deno.test("a shield checks that there is an error, not which one", () => {
// @ts-expect-error: this text is a comment, and nothing compares it to the error
const wrong: number = "oak";

assertEquals(typeof wrong, "string");
assertStrictEquals(wrong as unknown, "oak");
});
a shield checks that there is an error, not which one ... ok (43µs)

The directive does check one thing: if the next line has no error, the shield itself is the error, TS2578, the capture on the enum patterns page, so a shield cannot silently outlive the problem it was written for. It cannot be shielded either, because stacking a second directive just produces two of the same error, which makes it one of the few errors that cannot appear in a checked file at all. This page's answer to the comment-is-not-checked problem is the one every entry here uses: the shielded line goes into a copy of the file with the shields stripped, and the output blocks quote what deno check actually reports. For an ordinary project the honest advice is smaller: keep the comment on the shield accurate by hand, and put the important refusals in files you look at.

IsExact cannot decide a deferred conditional type

Inside a generic function, strip the shields from both claims and the checker refuses both:

Deno.test("IsExact cannot decide a deferred conditional type", () => {
type Kind = "text" | "number";
type ValueFor<K extends Kind> = K extends "text" ? string : number;

function deferred<K extends Kind>(kind: K): ValueFor<K> {
type Result = ValueFor<K>;

assertType<IsExact<Result, K extends "text" ? string : number>>(true);
assertType<IsExact<Result, K extends "text" ? string : number>>(false);

// The raw trick does decide it, against the same deferred shape.
assertType<Identical<Result, K extends "text" ? string : number>>(true);

return (kind === "text" ? "oak" : 123) as unknown as ValueFor<K>;
}

assertStrictEquals(deferred("text"), "oak");
assertStrictEquals(deferred("number"), 123);
});
Check programs/testing-types.test.ts
TS2345 [ERROR]: Argument of type 'true' is not assignable to parameter of type 'IsExact<Result, K extends "text" ? string : number>'.
assertType<IsExact<Result, K extends "text" ? string : number>>(true);
~~~~
at file:///programs/testing-types.test.ts:177:69

TS2345 [ERROR]: Argument of type 'false' is not assignable to parameter of type 'IsExact<Result, K extends "text" ? string : number>'.
assertType<IsExact<Result, K extends "text" ? string : number>>(false);
~~~~~
at file:///programs/testing-types.test.ts:178:69

Found 2 errors.

error: Type checking failed.

Two errors on two opposite claims, and both are needed, which is the shape of "this helper has no answer". Inside the function K is unknown, so ValueFor<K> is a deferred conditional type, the shape the conditional types page returns to, and IsExact applied to it is deferred too: its result is neither true nor false, so neither can be passed to assertType. The error names it plainly, a parameter whose type is IsExact<Result, K extends "text" ? string : number>, a type sitting there undecided. Shield both claims and keep the line that works:

Deno.test("IsExact cannot decide a deferred conditional type", () => {
type Kind = "text" | "number";
type ValueFor<K extends Kind> = K extends "text" ? string : number;

function deferred<K extends Kind>(kind: K): ValueFor<K> {
type Result = ValueFor<K>;

// @ts-expect-error: `true` is refused, because the helper has not decided
assertType<IsExact<Result, K extends "text" ? string : number>>(true);
// @ts-expect-error: and so is `false`, for the same reason
assertType<IsExact<Result, K extends "text" ? string : number>>(false);

// The raw trick does decide it, against the same deferred shape.
assertType<Identical<Result, K extends "text" ? string : number>>(true);

return (kind === "text" ? "oak" : 123) as unknown as ValueFor<K>;
}

assertStrictEquals(deferred("text"), "oak");
assertStrictEquals(deferred("number"), 123);
});
IsExact cannot decide a deferred conditional type ... ok (17µs)

The raw Identical trick does decide it, and says the deferred type equals its own deferred shape. So the two are not interchangeable after all: IsExact is better behaved for concrete types and gives up inside a generic function, where the older hack keeps working. If you hit this, the helper is not broken and neither are you.

print it when you cannot guess it

An assertion needs an expected answer. When you do not have one, write a deliberate mistake and read the message:

Deno.test("print it when you cannot guess it", () => {
type Prefixed<T> = {
[Key in keyof T & string as `$${Key}`]: T[Key];
};

const missing: Prefixed<{ score: number; title: string }> = {};

const wrongKey: Prefixed<{ score: number }> = { score: 1 };

assertEquals(Object.keys(missing), []);
assertEquals(Object.keys(wrongKey), ["score"]);
});
Check programs/testing-types.test.ts
TS2739 [ERROR]: Type '{}' is missing the following properties from type 'Prefixed<{ score: number; title: string; }>': $score, $title
const missing: Prefixed<{ score: number; title: string }> = {};
~~~~~~~
at file:///programs/testing-types.test.ts:197:9

TS2561 [ERROR]: Object literal may only specify known properties, but 'score' does not exist in type 'Prefixed<{ score: number; }>'. Did you mean to write '$score'?
const wrongKey: Prefixed<{ score: number }> = { score: 1 };
~~~~~
at file:///programs/testing-types.test.ts:199:51

Found 2 errors.

error: Type checking failed.

The empty object gets you a list of the computed keys, $score and $title. The wrong key gets you a suggestion, "Did you mean to write '$score'?", the excess property check from the object types page doing the guessing for you. Compare that with what a failed assertType tells you, which is that true is not assignable to false and nothing else: when you are exploring a computed type, built here from key remapping, the ground of the mapped types page, and a template literal, the ground of the template literal types page, the probe is better than the test. Shield both probes to keep them:

Deno.test("print it when you cannot guess it", () => {
type Prefixed<T> = {
[Key in keyof T & string as `$${Key}`]: T[Key];
};

// @ts-expect-error: the message lists the computed keys
const missing: Prefixed<{ score: number; title: string }> = {};

// @ts-expect-error: the message suggests the computed key by name
const wrongKey: Prefixed<{ score: number }> = { score: 1 };

assertEquals(Object.keys(missing), []);
assertEquals(Object.keys(wrongKey), ["score"]);
});
print it when you cannot guess it ... ok (68µs)

The run-time pins are their own small lesson: wrongKey really does have a score property at run time, because the shield let the wrong object through.

the compiler folds a type back into its name

One limit on the technique, and it decides which probe to write:

Deno.test("the compiler folds a type back into its name", () => {
type Point = { x: number; y: number };

const folded: keyof Point = 0;

const expanded: keyof Point & string = 0;

assertStrictEquals(folded as unknown, 0);
assertStrictEquals(expanded as unknown, 0);
});
Check programs/testing-types.test.ts
TS2322 [ERROR]: Type '0' is not assignable to type 'keyof Point'.
const folded: keyof Point = 0;
~~~~~~
at file:///programs/testing-types.test.ts:210:9

TS2322 [ERROR]: Type '0' is not assignable to type '"x" | "y"'.
const expanded: keyof Point & string = 0;
~~~~~~~~
at file:///programs/testing-types.test.ts:212:9

Found 2 errors.

error: Type checking failed.

The compiler prints 'keyof Point' for the first and '"x" | "y"' for the second. It folds a type back into a name whenever it has a name to fold into, and prints the members when it has already reduced them. Intersecting with string is what forces the reduction; Extract<keyof Point, string> does not, and neither does wrapping it in a template literal. For an object type, the identity mapped type {[K in keyof T]: T[K]} is the equivalent trick, one more deposit for the mapped types page. Shield the probes and the file is quiet again:

Deno.test("the compiler folds a type back into its name", () => {
type Point = { x: number; y: number };

// @ts-expect-error: a named type is printed folded
const folded: keyof Point = 0;

// @ts-expect-error: an intersection with string is printed expanded
const expanded: keyof Point & string = 0;

assertStrictEquals(folded as unknown, 0);
assertStrictEquals(expanded as unknown, 0);
});
the compiler folds a type back into its name ... ok (13µs)

That is the whole technique, and it needs no editor, no hover, and no import.

two sources of truth, one line keeping them in step

Deno.test("two sources of truth, one line keeping them in step", () => {
assertEquals(Object.keys(OutputFormat), [...OUTPUT_FORMAT_KEYS]);

assertType<Has<keyof typeof OutputFormat, "Json">>(true);
});
two sources of truth, one line keeping them in step ... ok (24µs)

Sometimes an assertion belongs in ordinary code, which is why the module-scope block ends with one. OutputFormat is an object and OUTPUT_FORMAT_KEYS is a tuple of its keys, written out by hand because Object.keys cannot produce a tuple, so there are two sources of truth. The assertType beside them is not in a test because the thing it protects is not a test: (typeof OUTPUT_FORMAT_KEYS)[number] must be exactly keyof typeof OutputFormat, the derive-the-type-from-the-value move the enum patterns page ends on, and the run-time assertion in this step checks the same agreement from the other side.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/branding.test.ts
Check programs/buffers-and-views.test.ts
Check programs/classes-as-values.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/enum-patterns.test.ts
Check programs/enums.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/function-types.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/interfaces-and-type-aliases.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/object-types.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/overloading.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/testing-types.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/typed-arrays.test.ts
Check programs/typing-arrays.test.ts
Check programs/typing-classes.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 9 tests from ./programs/branding.test.ts
...
running 12 tests from ./programs/buffers-and-views.test.ts
...
running 10 tests from ./programs/classes-as-values.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 12 tests from ./programs/enum-patterns.test.ts
...
running 7 tests from ./programs/enums.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 12 tests from ./programs/function-types.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 9 tests from ./programs/interfaces-and-type-aliases.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 15 tests from ./programs/object-types.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 8 tests from ./programs/overloading.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 14 tests from ./programs/testing-types.test.ts
one assertion per level ... ok (375µs)
an annotation says assignable to, and IsExact says exactly ... ok (20µs)
a type-level test vanishes when nothing checks it ... ok (21µs)
mutual assignability calls read-only types equal ... ok (14µs)
the trick that can tell them apart ... ok (18µs)
a naive predicate is ruined by distribution ... ok (16µs)
any is equal to everything unless you check for it ... ok (14µs)
the standard library has the detectors already ... ok (13µs)
Has asks the other question ... ok (20µs)
a shield checks that there is an error, not which one ... ok (39µs)
IsExact cannot decide a deferred conditional type ... ok (17µs)
print it when you cannot guess it ... ok (68µs)
the compiler folds a type back into its name ... ok (13µs)
two sources of truth, one line keeping them in step ... ok (24µs)
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 14 tests from ./programs/typed-arrays.test.ts
...
running 10 tests from ./programs/typing-arrays.test.ts
...
running 12 tests from ./programs/typing-classes.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 744 passed | 0 failed (986ms)

Fourteen tests, and the practice is short. Test the types of anything generic you export, because a function whose return type is computed is a function whose contract is a type, and the type deserves the same treatment as the value; everything else is usually covered by the annotations you already wrote. Put the assertions in the test file, beside the run-time ones, so that one command covers both levels, and never run tests with --no-check, the one command in this entry that makes a failing type-level test report success. Reach for a probe while exploring and an assertion once you know: the probe prints the answer, the assertion pins it down so the next person cannot change it by accident. Do not test what the language already guarantees, since an IsExact that repeats an annotation you just wrote is noise; the ones worth having are about types that were computed. And if a type needs three assertions to describe, consider making it simpler, which is not a rhetorical flourish: a type-level test suite is the sign that you have written something clever enough to need one, worth doing occasionally and worth noticing every time.