TypeScript satisfies Operator
The TypeScript satisfies operator, added in 4.9, exists to solve a problem you have almost
certainly hit without ever naming it. You want TypeScript to check that an object matches a
shape, and you also want to keep the precise types it inferred from the literal you wrote.
Before satisfies, those were two different requests and you could only make one of them.
The operator itself is one word in one position, so most of the work here is understanding what it replaces. That is easiest to see by watching the old approaches fail.
The problem
Here is a config object typed the normal way, with an annotation.
type ServerConfig = Record<string, string | string[]>
const annotated: ServerConfig = {
host: 'localhost',
ports: ['8080', '8081'],
}
// annotated.ports is string | string[] — TypeScript no longer knows it is an array
// annotated.ports.map((p) => p)
// ❌ Property 'map' does not exist on type 'string | string[]'The annotation did its job: it checked the object. But it also replaced everything
TypeScript had worked out about your actual values with the type you declared. ports is
an array, you can see on the line above that it is an array, and TypeScript now insists it
might be a string.
This is not a bug. A variable annotation is a declaration of what the variable is, and the value only has to be assignable to it. Once the declaration is made, the value's own type stops being interesting. That is what you want for a function parameter and what you almost never want for a config object.
Drop the annotation and the opposite failure shows up.
const inferred = {
host: 'localhost',
ports: ['8080', '8081'],
}
inferred.ports.map((p) => p) // ✅ the inferred type really is string[]
// But nothing is checking the shape any more:
const typo = {
host: 'localhost',
prots: ['8080'], // ✅ accepted — there is no type for it to fail against
}Inference is now perfect and validation is gone. You have a config object that compiles
happily with a misspelled key and breaks at runtime, in an app that is otherwise fully
typed. The usual workaround was to annotate anyway and then sprinkle as casts at every
read site, which trades one problem for a worse one.
What the TypeScript satisfies operator does
satisfies checks an expression against a type and then discards that type, keeping the
inferred one.
// ServerConfig is the same type declared above
const checked = {
host: 'localhost',
ports: ['8080', '8081'],
} satisfies ServerConfig
checked.host.toUpperCase() // ✅ string
checked.ports.map((p) => p) // ✅ string[], not string | string[]Both halves at once. The object is verified against ServerConfig, and checked.ports is
still string[]. The type is being used as a constraint on the value rather than as a
replacement for it.
The mental model that sticks: an annotation says "treat this value as a ServerConfig",
while satisfies says "prove this value is a ServerConfig, then forget I asked". The
check happens, the result is thrown away, and you keep the sharper type you started with.
Note where it goes. satisfies is a postfix operator on an expression, not a piece of a
declaration, so it sits after the value and works anywhere an expression works — including
inside a function argument or a return statement. That is a meaningful difference from an
annotation, which can only attach to a declaration site. If you want to verify an object
you are passing straight into a function call, satisfies is the only one of the three
tools that can do it without first pulling the value out into a named variable.
One consequence is that satisfies never changes what your code does, only what the
compiler accepts. Adding it to an existing object literal can turn up errors that were
always there, but it cannot alter the emitted JavaScript or the value at runtime. That
makes it a safe thing to add to code you do not fully trust yet: the worst case is that
TypeScript tells you something you did not want to hear.
satisfies vs annotation vs as
There are three ways to point a type at a value, and they do genuinely different things.
type Route = { path: string; method: 'GET' | 'POST' }
// Annotation — checked, but the value takes on the declared type
const annotatedRoute: Route = { path: '/users', method: 'GET' }
// annotatedRoute.method is 'GET' | 'POST'
// as — no widening complaint, but no real check either
const assertedRoute = { path: '/users', method: 'GET' } as Route
// assertedRoute.method is 'GET' | 'POST', and a wrong shape would be papered over
// satisfies — checked, and the narrow inferred type survives
const satisfiedRoute = { path: '/users', method: 'GET' } satisfies Route
// satisfiedRoute.method is 'GET'as is the dangerous one. It is an assertion, which means you are telling the compiler
what to believe and it stops arguing. satisfies is the exact inverse: it asks the
compiler to agree with you and fails loudly when it does not. If you have been reaching for
as to quiet an error on an object literal, satisfies is almost always what you actually
wanted, and it will usually reveal that the error was real.
The contextual typing is worth calling out separately. Because satisfies supplies a
contextual type to the expression, array literals can be inferred as tuples rather than
arrays:
type Palette = Record<'primary' | 'accent', string | [number, number, number]>
const palette = {
primary: '#2563eb',
accent: [37, 99, 235],
} satisfies Palette
palette.primary.toUpperCase() // ✅ string, no narrowing needed
palette.accent.map((channel) => channel / 255) // ✅ [number, number, number]With a plain annotation, both properties would be string | [number, number, number] and
every single access would need a type guard in front of it. This is the same widening
friction that makes union types tedious to work with when you
already know perfectly well which member you are holding.
Config objects and Record
The most common real-world use of satisfies is validating a lookup table against
Record.
type Feature = 'search' | 'billing' | 'exports'
const featureOwners = {
search: 'platform',
billing: 'payments',
exports: 'platform',
} satisfies Record<Feature, string>
featureOwners.search // ✅ autocomplete still knows the exact keys
// featureOwners.audit
// ❌ Property 'audit' does not exist on type '{ search: string; billing: string; exports: string; }'Two things are true at once here, and neither is true without satisfies. Every key is
checked against Feature, so a typo fails at the definition. And the object keeps its own
key list, so autocomplete offers you three specific properties instead of accepting any
string.
The bigger payoff arrives months later. Add a member to Feature and this object stops
compiling until somebody fills in the missing entry:
type FeatureWithAudit = Feature | 'audit'
// const featureOwners = {
// search: 'platform',
// billing: 'payments',
// exports: 'platform',
// } satisfies Record<FeatureWithAudit, string>
// ❌ Property 'audit' is missing in type '{ search: string; billing: string; exports: string; }'
// but required in type 'Record<FeatureWithAudit, string>'That is exhaustiveness checking on a plain object, for free, with no runtime code and no check anyone has to remember to write. The union of keys becomes the source of truth and every table built from it has to keep up with it.
as const satisfies
satisfies validates, but it does not freeze. The values inside the object still widen the
way any object literal's values widen. If you want the literals preserved too, stack it on
as const.
type Team = 'platform' | 'payments'
const teamOwners = {
search: 'platform',
billing: 'payments',
exports: 'platform',
} as const satisfies Record<Feature, Team>
type SearchOwner = (typeof teamOwners)['search'] // 'platform'The order matters and reads left to right: as const keeps the values as literal types,
satisfies then proves those literals are valid Team values, and
typeof lets you pull types back out of the object
afterwards. Without as const, SearchOwner would just be string.
That trio is the modern replacement for a lot of what enums were reached
for — a const object with as const satisfies gives you the same exhaustiveness and the
same autocomplete, without emitting any runtime code and without the quirks of the enum
type itself.
It also composes with template literal types when the keys follow a pattern:
type EventName = `user:${'created' | 'deleted'}`
const handlers = {
'user:created': () => 'welcome',
'user:deleted': () => 'goodbye',
} as const satisfies Record<EventName, () => string>
type Handled = keyof typeof handlers // 'user:created' | 'user:deleted'Misspell an event key and the object fails against EventName right there at the
definition, rather than silently never firing three files away at the call site.
Where satisfies does not help
It is a compile-time operator with a narrow job, and it is easy to over-read what it promises.
type Port = { value: number }
const port = { value: 8080 } satisfies Port
port.value = 9090 // ✅ still mutable — satisfies is not as const
// satisfies checks an expression, so it is not an annotation. This is not valid syntax:
// function listen(config satisfies Port) {}
// And it is erased, so it validates nothing about data you fetched:
// const data = (await response.json()) satisfies Port
// ❌ a claim about a value the compiler has never seenThree things to keep straight:
- It does not make anything readonly. That is
as const, and the two are complementary rather than alternatives. - It cannot type a function parameter or a return position. Those still want an annotation or an interface.
- It disappears in the emitted JavaScript.
satisfies Porton afetchresult is a claim you are making about data the compiler has never seen — parse it if you need certainty.
There is also a taste question about where it belongs, and it is worth being deliberate.
For a shared type used across many files, an annotation or an interface communicates intent
better and gives you a single place to change. satisfies earns its keep on concrete
values: config objects, lookup tables, route maps, theme definitions, event handler
registries — the places where the literal you wrote is the information you want to keep,
and where a widened type would throw away the only useful thing about it.
Summary
satisfies closes the gap between checking a value and inferring from it. An annotation
checks and widens, as neither checks nor widens, and satisfies checks without widening.
That one sentence covers most of the decisions you will make about it.
Reach for it when you are writing a concrete object literal and you want TypeScript to
verify the shape without flattening the details — most often a config or lookup table
validated against Record. Add as const in front of it when the literal values matter
too, and keep using annotations and interfaces for the shared types that describe your
domain rather than a specific value.
The official write-up is in the TypeScript 4.9 release notes.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges