Assertion Functions and satisfies
asserts signatures that narrow for the rest of a scope, satisfies for checking without widening, and const type parameters that preserve literals.
asserts signatures that narrow for the rest of a scope, satisfies for checking without widening, and const type parameters that preserve literals.
You write a function that throws when a value is missing, call
it, and the value on the next line is still possibly
undefined. The check ran, it provably narrowed the type, and
the checker did not notice — because a function returning void
tells it nothing.
Three constructs let you inform the checker of things it cannot
work out: asserts for narrowing that persists, satisfies for
checking without widening, and const type parameters for
literals at the call site. By the end of this lesson you will
know what each proves, what none of them verifies, and where
each is the right tool.
function assertDefined<T>(
value: T | null | undefined,
what: string,
): asserts value is T {
if (value == null) {
throw new Error(`${what} is missing`);
}
}const photo = photos.find((p) => p.id === id);
assertDefined(photo, "photo");
photo.name; // Photo - narrowed for the rest of the scopeasserts value is T means "if this returns, the value is a
T". No if at the call site, and the narrowing applies to
everything after — unlike a type predicate, which narrows only
inside a branch.
The simplest form takes no type at all:
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
assert(photo !== undefined, "no photo");
photo.name; // narrowedThree constraints catch people.
It must be declared with function. An arrow function
assigned to a const cannot carry an asserts signature unless
the variable has an explicit type annotation, because the
checker needs the declaration to be unambiguous.
The value must have an explicit type. Asserting on something implicitly typed does not narrow.
Every code path must throw or return. A function that sometimes falls through has lied, and nothing checks that.
Both teach the checker; they differ in shape.
function isPhoto(value: unknown): value is Photo; // returns boolean
function assertPhoto(value: unknown): asserts value is Photo; // throwsif (isPhoto(input)) { ... } // handle both cases
assertPhoto(input); // one case; the other throws
input.name;There is something to decide.
Filtering, branching, a fallback. Both outcomes are ordinary, so the caller gets a boolean and chooses.
There is nothing to decide.
The negative case is a failure. Returning would just make
every caller write the same throw.
One practical tiebreaker: the predicate composes with array methods, and the assertion cannot.
That works because filter has an overload for a predicate. It
is a real reason to prefer predicates for anything used with
collections.
Bad — a predicate whose body does not establish the claim.
Good — a body that checks every field the type claims.
The signature is a promise the checker accepts without
verifying. The first version says "this is a Photo" having
established only that it is an object, so every non-null object
in the program can pass — and the type system now confidently
propagates a claim that is false.
That makes a predicate exactly as dangerous as an as, with a
more reassuring appearance. Two habits contain it: keep the body
a direct check of precisely what the signature claims, and put
predicates for external data behind a schema instead, where the
check and the type come from one definition.
Different job: check a value against a type without adopting that type.
The type becomes the value's type.
An annotation is a target. config.retries is now
string | number, and .toFixed() does not compile.
The type is only checked against.
The inferred type survives, so config.retries is still
number and every key keeps its exact value type.
Three places it is the right answer.
Constant tables, where you want the keys checked and the values exact:
Deriving a union from a value:
The satisfies checks each member is a valid Status; the
as const keeps them literal.
Catching a missing key while keeping precision:
That is exhaustiveness, with the exact value types intact.
The third tool moves the as const burden from the caller to
the signature:
Without const, the argument infers as string[] and the
return is string. With it, callers get literal types by
default.
This is a library-design tool. It is what lets a routing table, a state machine or an options object give precise inference without documenting a trick — and, per the library lesson, adding or removing it is a breaking change, because it changes what every caller infers.
You can now tell the checker things it cannot infer, in three
different shapes, and you know the one property they share: none
of them is verified. A predicate and an assertion are both
assertions in the ordinary sense, which is why their bodies
deserve the same scrutiny as an as.
Next is Interop with Untyped Code, the closing lesson of
this course. Migrating incrementally, containing any at a
boundary, and measuring a migration so it actually finishes.
Before you move on, find a function in your code that throws
when a value is missing and add an asserts signature to it.
Then delete the redundant null check that follows every call.
That is a small, immediate improvement, and it is the most
underused feature in this lesson.
ASSERTION FUNCTIONS
function assertX(v: unknown): asserts v is X
function assert(c: unknown): asserts c
"if this returns, it is true" - narrows the REST of the scope
must be declared with `function`, not an arrow const
the value needs an explicit type
every path must throw or return
PREDICATES
function isX(v: unknown): v is X returns boolean
narrows inside a branch only
composes with filter: items.filter(isPhoto) -> Photo[]
predicate when the caller handles the negative case
assertion when the negative case is a failure
NEITHER IS VERIFIED
the signature is a promise the checker ACCEPTS
a body that checks less than the signature claims is as
dangerous as `as`, and looks safer
keep the body a direct check of exactly the claim
for external data, use a schema - one definition for both
satisfies - check WITHOUT widening
const c = { retries: 3 } satisfies Record<string, string | number>;
c.retries is still number; an annotation would widen it
constant tables with exact value types
as const satisfies readonly Status[] valid AND literal
satisfies Record<Union, T> exhaustiveness, precisely
const TYPE PARAMETERS
function pick<const T extends readonly string[]>(o: T): T[number]
callers get literals without writing as const
a library tool - adding or removing it is a BREAKING change
WHICH ONE
narrowing that persists -> asserts
handle the negative case -> a predicate
check without widening -> satisfies
"stop objecting" -> none of these; that is `as`const photos = items.filter(isPhoto); // Photo[]function isPhoto(value: unknown): value is Photo {
return typeof value === "object" && value !== null;
}
if (isPhoto(input)) {
input.size.toFixed(2); // compiles; crashes on { name: "x" }
}function isPhoto(value: unknown): value is Photo {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string" &&
typeof candidate.size === "number"
);
}const config = {
retries: 3,
endpoint: "https://api.example.com",
} satisfies Record<string, string | number>;
config.retries.toFixed(); // number - still preciseconst HANDLERS = {
pending: () => "Waiting",
done: (url: string) => url,
} satisfies Partial<Record<Status, (...args: never[]) => string>>;
HANDLERS.done("x"); // (url: string) => string - preservedconst STATUSES = ["pending", "done"] as const satisfies readonly Status[];
type Used = (typeof STATUSES)[number]; // "pending" | "done"const LABELS = {
pending: "Waiting",
done: "Ready",
} satisfies Record<Status, string>;
// error if Status gains a memberfunction pick<const T extends readonly string[]>(options: T): T[number] {
...
}
pick(["a", "b"]); // "a" | "b" - no as const needed