Phase 4 · Generics · Lesson 4.4
IntermediateType guards, assertions and satisfies
Teach the compiler what you know with type predicates and assertion functions, understand exactly what `as` and `!` do (and don't), and pick between annotation, `as` and `satisfies`.
25 min
Sometimes you know more than the compiler: this unknown is really a User, this element definitely exists, this array has no undefined left. TypeScript gives you several ways to pass that knowledge on. Some of them are checked, and some are promises the compiler takes on trust. Knowing which is which is the difference between type-safe code and code that only looks type-safe.
User-defined type guards: x is T
Built-in narrowing (typeof, in, instanceof) works inline. To reuse a check, move it into a function whose return type is a type predicate:
interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "number" &&
"name" in value &&
typeof value.name === "string"
);
}
const data: unknown = JSON.parse('{"id":1,"name":"Ada"}');
if (isUser(data)) {
console.log(data.name.toUpperCase());
// ^? const data: User
}value is User means: "if this returns true, treat the argument as a User". The function still returns a plain boolean at runtime; the predicate only affects narrowing at the call site.
The risk: predicates are trusted, not verified
TypeScript does not check that your function body matches the predicate. This compiles without a single error:
function isNumber(value: unknown): value is number {
return typeof value === "string"; // wrong check, no complaint
}
const input: unknown = "42";
if (isNumber(input)) {
input.toFixed(2); // compiles, crashes at runtime: toFixed is not a function
}A type guard is as dangerous as an as: it's an unchecked claim. Keep guards small, test them, and prefer validating real external data with a schema library in production code.
The else branch narrows too
A predicate narrows both ways. When it returns false, TypeScript removes the type in the else branch. That makes "partial" checks a trap:
function isPositive(value: string | number): value is number {
return typeof value === "number" && value > 0;
}
function handle(value: string | number) {
if (isPositive(value)) {
value.toFixed(2);
} else {
value;
// ^? (parameter) value: string
// Wrong for -5: it's a number, but the type says string.
}
}x is T means "true if and only if x is a T". A guard that can return false for a genuine T lies in the else branch. Name and type it as a boolean (isPositive(n: number): boolean) instead.
Inferred type predicates (TS 5.5)
Since TypeScript 5.5, the compiler infers a predicate for simple functions, so you rarely write one for filtering:
const values = [1, undefined, 2, undefined];
const defined = values.filter((x) => x !== undefined);
// ^? const defined: number[]
function isString(x: unknown) {
return typeof x === "string";
}
// Hover isString: function isString(x: unknown): x is stringBefore 5.5, defined was (number | undefined)[] and you needed (x): x is number => ....
Inference only happens when the predicate would be true in both directions. That's why these don't narrow:
const values = [1, undefined, 0];
const truthy = values.filter((x) => !!x);
// ^? const truthy: (number | undefined)[]
const viaBoolean = values.filter(Boolean);
// ^? const viaBoolean: (number | undefined)[]!!x is false for 0 too, so "false" does not mean "undefined", and TypeScript refuses to infer x is number. filter(Boolean) doesn't narrow because Boolean isn't declared as a predicate. Other conditions: no explicit return type on the function, a single return, and the parameter isn't reassigned.
Quick check
What is the type of names?
const raw: (string | null)[] = ["a", null, "b"];
const names = raw.filter((x) => x !== null);Assertion functions: asserts
A type guard narrows inside an if. An assertion function narrows everything after the call, because it throws when the check fails:
function assertIsString(value: unknown, label = "value"): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`${label} must be a string`);
}
}
function greet(input: unknown) {
assertIsString(input, "input");
return input.toUpperCase();
// ^? (parameter) input: string
}There's also a form without a type: asserts condition. It narrows by whatever the condition expression would narrow:
function assert(condition: unknown, message = "Assertion failed"): asserts condition {
if (!condition) throw new Error(message);
}
function area(size: number | null) {
assert(size !== null, "size is required");
return size * size; // size is number from here on
}Assertion functions must return void (they can't return a value), and like type predicates the body isn't verified. An assertion that forgets to throw narrows anyway.
The explicit annotation rule
This one catches everyone. Assertion functions only work when the compiler can see the asserts signature without inferring anything, because narrowing happens during control-flow analysis, before inference is finished.
Spot the error
Why does the last line fail, and what's the fix?
const assertDefined = <T,>(value: T): asserts value is NonNullable<T> => {
if (value == null) throw new Error("missing");
};
declare const port: number | undefined;
assertDefined(port);Show the answer
Assertions require every name in the call target to be declared with an explicit type annotation. The arrow function itself has an annotated return type, but the variable assertDefined doesn't have a declared type; its type is inferred from the arrow. For assertions, every name in the call (assertDefined, or obj and method in obj.method(...)) must have an explicit type.
Fix it with a function declaration (simplest), or annotate the variable:
function assertDefined<T>(value: T): asserts value is NonNullable<T> {
if (value == null) throw new Error("missing");
}
// or: annotate the const with a function type
const assertDefinedArrow: <T>(value: T) => asserts value is NonNullable<T> = (value) => {
if (value == null) throw new Error("missing");
};
declare const port: number | undefined;
assertDefined(port);
const p = port;
// ^? const p: numberType assertions: as
value as T tells the compiler "treat this as a T". It changes nothing at runtime: no conversion, no check.
const el = document.getElementById("app") as HTMLDivElement;
// ^? const el: HTMLDivElementYou're claiming the element exists and is a div. If it's null or a span, you find out at runtime.
TypeScript does push back on assertions that can't possibly be right. as is allowed only when one type is assignable to the other (in either direction):
const id = "42";
// @ts-expect-error -- Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other.
const n = id as number;
const wide = id as string | number; // widening: fine
const partial = {} as { name: string }; // narrowing to a subtype: allowed, and a lieNote the last line: {} has no name, yet the assertion is allowed because { name: string } is assignable to {}. Downcasting is permitted and unchecked.
The double assertion: as unknown as T
When TypeScript refuses, you can go through unknown, which every type converts to and from:
const id = "42";
const n = id as unknown as number; // compiles; n is a string at runtimeThe error message even suggests it. Treat it as a red flag in code review: it means "I'm overriding the type system completely". Legitimate uses exist (test doubles, low-level interop), but they should come with a comment.
Non-null assertion: !
Postfix ! removes null and undefined from a type. Like as, there's no runtime check:
const cache = new Map<string, number>();
cache.set("a", 1);
const a = cache.get("a")!;
// ^? const a: number
const b = cache.get("missing")!; // also number, actually undefined
let total!: number; // definite assignment: "I'll set it before use"
queueMicrotask(() => (total = 1));Prefer a real check (?? defaultValue, an if, or an assertion function) when the value can legitimately be missing. Use ! only when an invariant the compiler can't see guarantees it, like the set just before the get.
Quick check
Which line is a compile error?
const count = 5;
const a = count as number | string; // Line A
const b = (JSON.parse("1") as unknown) as number; // Line B
const c = count as string; // Line C
const d = {} as { id: number }; // Line Dsatisfies: check without changing the type
Annotations and as both replace the inferred type. Often you want to check a value against a type but keep the precise inferred type. That's satisfies (TS 4.9):
type Color = string | [number, number, number];
// Annotation: checked, but every property is now the wide union.
const annotated: Record<string, Color> = { red: [255, 0, 0], green: "#00ff00" };
// @ts-expect-error -- Property 'toUpperCase' does not exist on type 'Color'.
annotated.green.toUpperCase();
// satisfies: checked, and the specific types are kept.
const palette = { red: [255, 0, 0], green: "#00ff00" } satisfies Record<string, Color>;
palette.green.toUpperCase();
palette.red.map((c) => c / 255);
// ^? const palette: { red: [number, number, number]; green: string; }Two more things the annotation version loses and satisfies keeps: the exact keys (palette.blue is an error, annotated.blue is not), and literal types when combined with as const.
type Route = { path: string; auth: boolean };
const routes = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
} as const satisfies Record<string, Route>;
const adminPath = routes.admin.path;
// ^? const adminPath: "/admin"
const bad = {
// @ts-expect-error -- Type 'string' is not assignable to type 'boolean'.
home: { path: "/", auth: "no" },
} satisfies Record<string, Route>;satisfies still does full checking, including excess property checks on object literals. It just doesn't widen the result to the target type.
Annotation vs as vs satisfies
const x: T = value | const x = value as T | const x = value satisfies T | |
|---|---|---|---|
Resulting type of x | T | T | the inferred type of value |
Is value checked against T? | Yes, assignability | Only "comparable" (either direction) | Yes, assignability |
| Excess property check on literals | Yes | No | Yes |
| Can it hide a bug? | Rarely | Yes, it's a claim, not a check | Rarely |
| Use it when | You want the variable to be a T (e.g. may be reassigned, public API) | You genuinely know more than the compiler (DOM, parsed data, tests) | You want validation and the precise inferred type (config objects, lookup tables) |
Rule of thumb: reach for annotation or satisfies first. as is the escape hatch.
Try it
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number };
function isShape(value: unknown): value is Shape {
if (typeof value !== "object" || value === null || !("kind" in value)) return false;
if (value.kind === "circle") return "radius" in value && typeof value.radius === "number";
if (value.kind === "square") return "size" in value && typeof value.size === "number";
return false;
}
function assertShape(value: unknown): asserts value is Shape {
if (!isShape(value)) throw new TypeError("Not a shape");
}
const parsed: unknown = JSON.parse('{"kind":"circle","radius":2}');
assertShape(parsed);
const area = parsed.kind === "circle" ? Math.PI * parsed.radius ** 2 : parsed.size ** 2;
console.log(area.toFixed(2));
const defaults = {
circle: { kind: "circle", radius: 1 },
square: { kind: "square", size: 1 },
} satisfies Record<Shape["kind"], Shape>;
// Try: remove the `square` entry, or change radius to "1", and read the errors.
console.log(defaults.circle.radius);▶ Try it in the TypeScript Playground
Recap
x is Tpredicates make a reusable narrowing check; they narrow theifand theelse.- Predicates and assertion functions are trusted, not verified. A wrong body silently lies.
- A predicate must be true iff the value is a
T; partial checks break theelsebranch. - TS 5.5 infers predicates for simple functions like
x => x !== undefined, but not forx => !!xorfilter(Boolean). asserts x is Tandasserts conditionnarrow after the call; they must throw to be honest.- Assertion calls need an explicit type on every name in the call target: use a
functiondeclaration. asonly requires the types to overlap; downcasts are unchecked.as unknown as Toverrides everything.!stripsnull/undefinedwith no runtime check;let x!: Tis a definite assignment assertion.satisfieschecks like an annotation but keeps the inferred (narrower) type; pair withas constfor literal config.
Interview cards
1 / 8