as is you overruling the type checker. It is the one construct in this language that
can turn a compiling program into a crashing one, so it is worth knowing precisely what it does,
when it is legitimate, and what to reach for instead — which, more often than not, is
satisfies.
What as does
const value: unknown = 'PAID';
const status = value as OrderStatus;No check happens. Nothing is converted. The compiler simply treats value as an
OrderStatus from that point on, because you said so.
Contrast with an annotation, which asks rather than tells. The first line here does not compile; the second does:
const a: OrderStatus = value; // error — unknown is not assignable
const b = value as OrderStatus; // fine — you have taken responsibilityThere is an older angle-bracket syntax, <OrderStatus>value, which is
equivalent and unusable in .tsx files because it collides with JSX. Do not use it.
It cannot assert anything you like
One guardrail exists: the two types must overlap in one direction or the other.
// This does not compile.
const n = 'PAID' as number;
// ~~~~~~~~~~~~~~~~
// Conversion of type 'string' to type 'number' may be a mistake because neither
// type sufficiently overlaps with the other.The escape hatch is to go via unknown, which overlaps with everything:
const n = 'PAID' as unknown as number;That double assertion is a genuine signal. It is TypeScript telling you the two types have nothing to do with each other, and you replying that you would like to proceed anyway. Sometimes that is correct — but it should never be casual, and it deserves a comment saying why.
The Angular admin store has one, and it is a fair example of the legitimate case:
map((action): Outcome =>
action.type === success.type
? { ok: true }
: { ok: false, failure: (action as unknown as { failure: ApiFailure }).failure },
),The action arriving off the stream is typed as a bare { type: string }, because the
helper is written to work with any pair of actions. The code has just checked
action.type === failure.type, so it knows the payload is there — but that knowledge
came from a runtime comparison the type system cannot follow back to a shape. The assertion records
what the check established.
Where assertions are genuinely right
Four situations account for nearly all the honest ones.
Parsed JSON. JSON.parse returns any, and something has
to decide what it is. The pizza app does it once, in the file that owns the network:
if (!response.ok) {
const errorBody = parsed as ApiErrorBody | null;Note that it does not assert the success case in the same way — that comes back as the caller's
T, which is the same claim made one level up. Either way the assertion is confined to
one module rather than repeated at forty call sites.
DOM queries. querySelector cannot know what it will find:
const input = document.querySelector('#email') as HTMLInputElement;A value the framework guarantees. Angular's HttpErrorResponse
carries an untyped error blob, so the interceptor names it:
const body = (error.error ?? null) as ApiErrorBody | null;Deliberately fixing a literal's type — which is as const, below,
and is not really the same operation at all.
The non-null assertion
A postfix ! removes null and undefined:
const el = document.getElementById('root')!; // HTMLElement, not | nullSame warning, smaller and easier to type. It is most defensible where the value's existence is guaranteed by something outside the type system — an element the framework mounts into, a map key you just set.
Where it is least defensible is inside business logic, because there
narrowing would have proved the same thing for free.
If you find yourself writing order!.total, the question to ask is why
order is nullable at that point at all.
The related ! on a class field is the definite assignment assertion from
lesson 12 — different syntax position, same kind of
promise.
as const
This one is different in kind, and much safer. as const asks for the
narrowest type rather than a different one, so it never lies:
const a = [7, 30, 90]; // number[]
const b = [7, 30, 90] as const; // readonly [7, 30, 90]Three things happen at once. Literals stay literal instead of widening; arrays become readonly
tuples; object properties become readonly. The app uses it exactly where the values
themselves matter:
const RANGES = [7, 30, 90] as const;and on a single value, to stop it widening into a plain string:
category: 'MEAT' as const,That second one is from the Angular cart service, building a topping object. Without
as const, category infers as string, the object is no longer
assignable to Topping — whose category is
ToppingCategory — and you get an error that looks like it is about the object when it
is about one field.
That is the widening problem from lesson 3, and
as const is its usual fix.
satisfies
Added in TypeScript 4.9, and it resolves a genuine dilemma. Consider a lookup you want checked against a type, but whose exact values you also want to keep:
const STATUS_LABEL: Record<OrderStatus, string> = {
PENDING_PAYMENT: 'Awaiting payment',
PAID: 'Paid',
PREPARING: 'In the oven',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
};
STATUS_LABEL.PAID; // string
STATUS_LABEL.SHIPPED; // does not compile, which is what you wantedThe annotation checks the keys, which is what you wanted. But it also widens the values:
every label is now string, and the fact that PAID is specifically
'Paid' has been thrown away.
Drop the annotation and the values stay precise — but nothing checks the keys any more, so a
missing status goes unnoticed. You cannot have both. That is what satisfies is
for:
const STATUS_LABEL = {
PENDING_PAYMENT: 'Awaiting payment',
PAID: 'Paid',
PREPARING: 'In the oven',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
} satisfies Record<OrderStatus, string>;Now a missing or misspelt key is still an error, and STATUS_LABEL.PAID has
type 'Paid'. The type is checked against but not applied.
The rule is easy to remember once you have the three side by side:
| Form | Checks the value? | Changes the inferred type? |
|---|---|---|
const x: T = … | yes | yes — widens to T |
const x = … as T | no | yes — forces T |
const x = … satisfies T | yes | no |
satisfies is what most people reaching for as actually wanted: they
wanted the object checked, not the checker silenced.
Replacing an assertion with a check
Most assertions in application code can be turned into something honest, and the transformation is usually short. Here is the shape it takes.
Before — an assertion carrying an assumption:
const raw = localStorage.getItem('pizza.cart');
const cart = JSON.parse(raw!) as ServerCart;
renderCart(cart);Two claims there, neither checked. That raw is not null — it is, the
first time anyone visits. And that whatever was stored is a ServerCart — it was, until
the shape changed in a release and every returning visitor got a blank page.
After — the same code, with the claims turned into questions:
function readCart(): ServerCart | null {
const raw = localStorage.getItem('pizza.cart');
if (raw === null) return null;
const parsed: unknown = JSON.parse(raw);
if (!isServerCart(parsed)) return null;
return parsed;
}Four extra lines, no assertions, and the failure modes are now handled rather than assumed. Note
the : unknown annotation on the parse — the trick from
lesson 4, downgrading any so the
compiler insists on the check that follows.
The guard itself is a type predicate, and how thorough it needs to be depends on how much you
trust the source. For localStorage, which contains whatever your own code left there
several releases ago, thorough is the right answer.
A word on the other as
One piece of syntax that is not an assertion at all, and reads as though it might be. In a mapped
type, as means key remapping:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};Nothing is being overruled there — it is transforming each key as it goes. Same keyword, unrelated
meaning, and covered in lesson 15.
Likewise as const, which asks for the narrowest type rather than asserting a different
one.
So "count the ases" is a poor code-quality metric on its own. The one to look for is
as SomeType — a bare type on the right — and its louder cousin
as unknown as.
A rule for reviews
Every as and every ! is a place the type system was told to stop
working, so each one deserves the same question: what do you know that the compiler does
not?
Good answers exist — the shape of a response, an element the framework mounted, a check made two lines up that the type system cannot follow. Write it in a comment, and confine it to the boundary module rather than letting it spread inward.
Bad answers all sound the same: the error was inconvenient. In that case the error was usually
right, and the fix is a narrowing check, a better type, or satisfies.
A last practical point about where assertions live. Both halves of the pizza app confine theirs
to one file each — lib/api.ts and core/api-error.ts, the two modules that
talk to the network. Nothing further in has any, because by the time a value reaches a component it
has a real type.
That is the pattern worth copying. Assertions are a boundary concern: they belong where untyped data enters, made once, next to a comment explaining the claim. An assertion in the middle of a component is nearly always a sign that the boundary above it was not typed properly.
Next
Modules and Type-Only Imports — import
type, why a bundler needs you to say it, and what happens to your imports at build time.