Skip to content
AtomicReps

The satisfies operator

A lesson from TypeScript: What the Type System Checks. Play it above, or read it through below.

Level I · Type Foundations
  • The type you name after satisfies adds nothing to the value's type

    The common read: satisfies X checks the value against X, so whatever X declares, readonly included, is what the value's type then carries

    The compiler checks the expression against the type after satisfies, and the expression's type does not change when the check passes.

    The operator does only that: no modifier, no optionality, no union member, no index signature and no alias name crosses from the right-hand side to the binding, which is why satisfies Readonly<T> makes nothing read-only, satisfies Partial<T> makes nothing optional, and satisfies SomeAlias never makes the editor show you SomeAlias on hover. The constraint checks the value and does not change its type.

    For example, a shared settings object is declared against a Readonly constraint, the first request that uses it changes it, and the on-call engineer treats the result as cache poisoning because the second request read a value the first request wrote and nothing in the module shows which code wrote it. To make the value read-only, use as const. A Readonly constraint does not freeze the value.

  • satisfies checks as strictly as an annotation, and it emits no code

    The common read: satisfies does not change the type, so it is the looser of the two and it will not catch a misspelled key the way an annotation would

    Does leaving the type alone make satisfies the looser check?

    Not changing the type and not checking the type are two different things. satisfies

    Under 7.0.2, the release notes' own favoriteColors example with a platypus key is error TS2353: Object literal may only specify known properties, and 'platypus' does not exist in type 'Record<Colors, unknown>', and dropping blue from a Record<Colors, string> is error TS2741: Property 'blue' is missing, which is character-for-character the diagnostic the annotated form gives for the same omission.

    satisfies has no effect at runtime: const routes = { home: "/" } satisfies Record<string, string> emits const routes = { home: "/" }, so the clause is gone and there is no validation of anything at all once the process starts.

    Developers who expect it to validate data use it on a payload received over the network: (await res.json()) satisfies Payload compiles because res.json() returns any, and any passes every check and stays any. Nothing checked the payload's shape, at compile time or at runtime. Use satisfies on values you wrote, and use a runtime validator on values you received.

  • An annotation replaces the inferred type at every read

    The common read: a type annotation on a config object is a check that the object matches, so every property still reads back as the type of the value written at that key

    You annotate a config object to catch a typo. What else did the annotation change?

    An annotation does not run against your object; it is

    Write const palette: Record<Colors, string | RGB> = { ... } and palette.green is string | RGB at every read, even though you wrote the string "#00ff00" at that key, because the declared type governs reading and writing both. The typo you added the annotation to catch does get caught.

    The annotation also makes palette.green.toUpperCase() stop compiling at the consumer, in a file that is not in the diff. For example, weeks after a PR titled "type the theme config" added the annotation, a reviewer asks why somebody put a cast on a colour, and the question goes to the file that added the cast.

    The same object with satisfies Record<Colors, string | RGB> in place of the annotation compiles that call without errors under 7.0.2. Annotate a config object when you want every property read at the annotated type, and account for the cost: if any consumer needs the specific value type, the annotation causes the errors, not the consumer.

  • satisfies keeps the inferred type, and as const decides what inference produces

    The common read: satisfies preserves what I wrote, so the string literals in a checked object stay literal types and as const is only for the cases where I skipped the check

    satisfies preserves the inferred type, and inference can produce a wider type than the literal you wrote. The inferred type is what inference produces at that position, under the contextual type the constraint supplies, and a mutable object property whose contextual type is string infers string, which is the same widening rule that turns { counter: 0 } into counter: number and has nothing to do with the operator at all.

    The two operators have separate jobs: as const decides what inference produces, satisfies decides whether the result is acceptable, and writing them together, as const satisfies Record<string, string>, gives literal values that were also checked. Order is not a preference here, because as const has to have already applied before the constraint reads the type.

    The constraint's shape can change the answer without contradicting the rule that the constraint adds nothing to the value's type: a constraint of { method: "GET" | "POST" } leaves method as "GET", because the contextual type at that property is a literal union and inference does not widen a member of it, so the constraint copied nothing onto the value.

    The bug that as const satisfies prevents is a route helper that used to reject a typo and now accepts any string, which the team finds when a customer emails a screenshot of an empty page whose URL is a route nobody defined. Use as const satisfies whenever the exact values matter, and accept the cost of as const: the whole object is readonly and the tuples are readonly.

  • as replaces the type and skips the check

    The common read: as X and satisfies X are two spellings of the same thing, so the shorter one is fine and the compiler checks the object either way

    Two spellings, as X and satisfies X. Which one checks the object?

    as replaces the type, which satisfies never does, and drops the full check, which satisfies always runs, and the combination is

    It replaces the expression's type with the one you named, so keyof typeof resolves to the constraint's keys and every property reads back at the constraint's type, and it runs a weaker check than assignability: the compiler accepts as when either type is assignable to the other, so an object with a missing key passes, and only a value with no overlap, such as "x" as number, fails with TS2352.

    The handbook states the runtime half: "Because type assertions are removed at compile-time, there is no runtime checking associated with a type assertion. There won't be an exception or null generated if the type assertion is wrong." Under 7.0.2, value as Record<Colors, string> compiles without errors with blue absent, z.blue.toUpperCase() compiles without errors too, and node throws TypeError: Cannot read properties of undefined (reading 'toUpperCase') at the first request that renders a blue swatch.

    That production stack trace names the component that read the colour and not the module that asserted it, so the first PR adds an optional chain on the read and the incident happens again a month later on a different key. Use as where you know something the compiler cannot, which is the handbook's own framing of it, and use satisfies everywhere you are describing an object you wrote, because there you know nothing the compiler could not check for you.