Back to list
September 11, 2026•4 min read

TypeScript satisfies: narrow literals without widening the type

A type annotation widens object literals. satisfies checks the shape and keeps narrow inference, so autocomplete and narrowing still work.

TypeScriptFrontendReact

You annotate a config object "for safety," and suddenly palette.green.toUpperCase() blows up in the type checker. The value is still a string at runtime. TypeScript just forgot.

That is the trap: a type annotation replaces the inferred type and widens literals. satisfies checks the shape and keeps narrow inference so autocomplete and narrowing still work. Official write-up: TypeScript 4.9 — The satisfies operator.

Problem: annotations eat your literals

Say you map route keys to loaders in a React or Next.js app (example):

type RouteId = "home" | "blog" | "about";
type Loader = () => Promise<{ title: string }>;

const loaders: Record<RouteId, Loader> = {
  home: async () => ({ title: "Home" }),
  blog: async () => ({ title: "Blog" }),
  about: async () => ({ title: "About" }),
};

// Fine... until you want the key list as a tuple of literals:
const routeIds = Object.keys(loaders);
// string[] - not ("home" | "blog" | "about")[]

Or the classic palette case from the docs. Annotate with Record and a typo like bleu is caught, but palette.green becomes string | RGB, so string methods disappear.

Solution: satisfies without widening

Use satisfies when you care about the value's precise type and still want a shape check:

type Colors = "red" | "green" | "blue";
type RGB = [red: number, green: number, blue: number];

const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
} satisfies Record<Colors, string | RGB>;

// Still a string - methods work
const green = palette.green.toUpperCase();

// Still a tuple - index access stays precise
const r = palette.red[0];

What changed:


  1. Missing or misspelled keys fail the check (same as a Record annotation).

  2. Property types stay specific ("#00ff00" stays string, not string | RGB).

  3. You get the guardrail without throwing away inference.

  4. For route maps, the same idea:

type RouteId = "home" | "blog" | "about";
const loaders = {
  home: async () => ({ title: "Home" }),
  blog: async () => ({ title: "Blog" }),
  about: async () => ({ title: "About" }),
} satisfies Record<RouteId, () => Promise<{ title: string }>>;

type KnownRoute = keyof typeof loaders; // "home" | "blog" | "about"

Optional combo when you also want readonly literals: as const satisfies SomeType (order matters: as const first, then satisfies). That pattern shows up a lot in config and i18n maps; see also Total TypeScript on satisfies.

Pitfalls and limits

  • Prefer satisfies for object literals you will read with property-specific logic (configs, route tables, feature flags).
  • Prefer a plain annotation when you want the widened type (e.g. a mutable Record you assign into later).
  • satisfies is a compile-time check only. It does not exist at runtime and does not validate API payloads.
  • Needs TypeScript 4.9+. Older projects: upgrade typescript (and your editor TS version) first.
  • It does not deep-freeze or copy. Mutability is unchanged unless you add as const / readonly.
  • Excess property checks still follow normal object-literal rules; nested objects may need their own satisfies.
  • If the right-hand constraint is too loose (unknown, wide unions), you get little benefit. Tighten the constraint to the shape you actually mean.
  • When a mid-level frontend says "I typed this object and lost my autocomplete," the usual culprit is annotation widening. satisfies keeps both the check and the narrow type. Use it on the next config map instead of another annotation "for safety."