TypeScript – Assertions, as const and satisfies

August 21, 20268 min readUpdated 9/5/2026

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 responsibility

There 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 | null

Same 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 wanted

The 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:

FormChecks the value?Changes the inferred type?
const x: T = …yesyes — widens to T
const x = … as Tnoyes — forces T
const x = … satisfies Tyesno

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 Importsimport type, why a bundler needs you to say it, and what happens to your imports at build time.