codejump

AcademyTypeScript

Type assertions: as vs satisfies

What a type assertion actually does to your program, the two places it is defensible, and why satisfies gives you the check without losing the type.

3 min readUpdated

3 exercises

Waiting at the end of this page.

as does not convert anything

A type assertion changes what the compiler believes. It does not touch the value, run a check, or produce any output:

TypeScript
const value = '42' as unknown as number;

value.toFixed(2);  // compiles
                   // TypeError: value.toFixed is not a function

That is the whole feature. as is you telling the compiler you know better, and from that line on it stops verifying the thing you claimed. Every runtime error that begins "but TypeScript said it was..." starts at an assertion someone wrote to make an error go away.

The non-null assertion is the same promise in one character:

TypeScript
const el = document.getElementById('app')!;
el.classList.add('ready');   // TypeError if the element is not there

Where it is defensible

Two places, and they have something in common — you know something the type system cannot:

TypeScript
// 1. Immediately after a check the compiler cannot follow.
if (!isUser(payload)) throw new Error('bad payload');
const user = payload as User;   // better: make isUser a type predicate

// 2. Minting a branded type, in the one function allowed to.
function toUserId(raw: string): UserId {
  if (!/^u_/.test(raw)) throw new Error('not a user id');
  return raw as UserId;        // validated one line above
}

Both have the validation next to the assertion. An as more than a couple of lines from the proof of its own claim is a bug with a delay on it.

satisfies: check it without widening it

The usual reason people reach for as on an object is that annotating it destroys the detail they wanted:

TypeScript
type Config = Record<string, string | number>;

const config: Config = {
  host: 'localhost',
  port: 5432,
};

config.port.toFixed();   // Error: 'string | number' has no 'toFixed'

The annotation checked the object and then flattened it. as Config avoids the flattening by skipping the check, which is worse. satisfies does both jobs:

TypeScript
const config = {
  host: 'localhost',
  port: 5432,
} satisfies Config;

config.port.toFixed();   // fine — port is still number
config.hostt;            // Error: no such property

It verifies the value against the type, and leaves the inferred type alone. That is the operator you want almost every time you were about to write as on a literal.

You writeChecked?Type you get
const x: T = { ... }yesT — detail lost
const x = { ... } as TnoT — detail lost, no check
const x = { ... } satisfies Tyesthe literal's own type

The combination worth remembering

satisfies pairs with as const — one keeps the literals narrow, the other proves they are the right ones:

TypeScript
const ROUTES = {
  home: '/',
  user: '/users/:id',
} as const satisfies Record<string, string>;

type RouteName = keyof typeof ROUTES;   // 'home' | 'user'
ROUTES.user;                            // '/users/:id', not string

Add a route whose value is a number and the build fails. Read a route and you still get its exact string. Neither annotation nor assertion gives you both.

The rule

Reach for satisfies when you want a value checked, an annotation when you want a value widened to an interface on purpose, and as only with the proof on the previous line. Then make them countable: a codebase where every as is a deliberate, commented exception is one where the type system's claims are still worth something.

Now practice it

2 challenges · 1 quiz

Reading this page is the cheap half. These are the exercises that make you use it.