10LOC
TypeScriptintermediate

satisfies to validate shape while keeping literal types

Published August 18, 2026

type RGB = readonly [red: number, green: number, blue: number];

const palette = {
  primary: [15, 98, 254],
  danger: [220, 38, 38],
  success: [22, 163, 74],
} satisfies Record<string, RGB>;

export const toHex = (key: keyof typeof palette): string =>
  palette[key].map((channel) => channel.toString(16).padStart(2, "0")).join("");

What

palette is checked against Record<string, RGB> with satisfies instead of a : Record<string, RGB> annotation, so each channel array is still typed as the exact 3-tuple RGB, not widened to the annotation.

Why it matters

Annotating palette: Record<string, RGB> would type-check the values, but it also makes that the type of palette going forward — every key becomes string, so palette.primary doesn't exist as far as the type checker is concerned; only palette[anyString] does, and the specific key list is gone. Leaving the annotation off entirely gets the literal keys back, but loses the check: a typo'd value like [15, 98] (missing a channel) would silently pass. satisfies gets both: TypeScript verifies the expression against Record<string, RGB> and then keeps the inferred type of the expression — the literal keys and the tuple shape — rather than replacing it with the annotation's type.

How it works

  • RGB is a fixed 3-tuple, not number[] — so an array literal with the wrong number of elements fails the satisfies check.
  • palette's inferred type is exactly what you'd get without the satisfies clause at all: an object with keys "primary" | "danger" | "success", each mapped to readonly [number, number, number].
  • toHex relies on that: keyof typeof palette is the literal key union, not string, so passing an unrelated string doesn't type-check.

Gotchas

  • satisfies checks assignability, it doesn't change variance rules — a mutable array literal like [15, 98, 254] is still allowed against the readonly tuple RGB, because mutable arrays are assignable to their readonly counterparts, just not the reverse.
  • It's easy to reach for satisfies and then still add a redundant type annotation next to it. If you need the annotation's type to be the actual type of the variable, use the annotation; satisfies is specifically for when you want the inferred type to win.

Related

  • A plain type annotation (: Record<string, RGB>) — validates the same shape but widens the variable to that annotation's type, losing the literal keys.
  • as const — also preserves literal types, but doesn't check the value against any external shape the way satisfies does.