Skip to content
AtomicReps

The satisfies operator

A lesson from Types That Earn Their Keep. Play it above, or read it through below.

  • Nothing comes back from the type you name

    The common read: satisfies X checks the value against X, so whatever X declares, readonly included, is what the value comes back holding

    The type after satisfies is a test the expression takes, and the expression is unchanged by having passed it.

    That is the whole operator: 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> locks nothing, satisfies Partial<T> makes nothing optional, and satisfies SomeAlias never makes the editor show you SomeAlias on hover. The constraint interviews the value; it does not dress it.

    The version that reaches a pager is a shared settings object declared against a Readonly constraint, mutated by the first request that touches it, and escalated as a cache poisoning problem because the value the second request read was written by the first and nothing in the module says who wrote it. If you want the value locked, lock the value: as const is the lever, the constraint is not.

  • satisfies is the stricter read, and it emits nothing

    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.

    What it does not do is reach 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.

    The belief that it is the permissive option is what puts it on a payload that arrived over the network, and the incident that follows gets filed as a parser bug, because the object that broke has a compile-time type asserting it cannot be that shape. Use it on values you wrote, and reach for a runtime validator on values you received.

  • An annotation is the type. It is not a check on it.

    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 you have declared that palette.green is string | RGB, which it now is, and the string literal one line above stops being consulted the moment the annotation exists, because the declared type governs reading and writing both. The typo you added the annotation to catch does get caught.

    What you also bought is that palette.green.toUpperCase() stops compiling at the consumer, in a file that is not in the diff. It arrives as a review comment asking why somebody put a cast on a colour, filed against the file that added the cast, weeks after a PR titled "type the theme config" landed the annotation that caused it.

    The same object with satisfies Record<Colors, string | RGB> in place of the annotation compiles that call clean under 7.0.2. Annotate a config object when you want every property read at the annotated type, and price it honestly: if any consumer needs the specific value back, the annotation is what is costing you, not the consumer.

  • satisfies keeps the inference. 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 the inferred type is not the source text. It is whatever 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.

    Two levers, two 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.

    This is also the one place the constraint's shape changes the answer without contradicting S2: a constraint of { method: "GET" | "POST" } leaves method as "GET", because the contextual type at that property is a literal union and inference had no reason to widen, so nothing was copied back and nothing needed to be.

    The bug this prevents is a route helper that used to reject a typo and now takes any string, discovered when a customer emails a screenshot of an empty page and the URL in it is a route nobody ever defined. Reach for as const satisfies whenever the values matter as values, and accept the price as const charges, which is that the whole object is readonly and the tuples are frozen.

  • as rewrites 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 does both of the things this lesson has spent four beats saying satisfies does not do, and it does them

    It replaces the expression's type with the one you named, so keyof typeof collapses to the constraint's keys and every property reads back at the constraint's type, and it performs no assignability check in the process, because an assertion is a claim you are making rather than one the compiler is testing.

    The handbook is explicit about the second half and its consequence: "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, { red: "#f00", green: "#0f0" } as Record<Colors, string> compiles clean with blue absent, asserted.blue.toUpperCase() compiles clean too, and node throws TypeError: Cannot read properties of undefined (reading 'toUpperCase') at the first request that renders a blue swatch.

    That production stack names the component that read the colour and not the module that asserted it, which is why the fix in the first PR is an optional chain on the read and the incident recurs a month later on a different key. Use as where you genuinely 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.