You have a config object. You want two things at once:
- TypeScript should check it against a type
- TypeScript should remember the exact values you wrote
Before TS 4.9 you could have either one. Not both.
Annotating loses the detail
type Config = Record<string, string | number>
const config: Config = {
host: 'localhost',
port: 3000,
}
config.port.toFixed() // Error: Property 'toFixed' does not exist on 'string | number'
The annotation checked the object, then widened every value to string | number. You knew port was a number. The compiler no longer does.
Omitting the annotation loses the check
const config = {
host: 'localhost',
prot: 3000, // typo, nobody notices
}
config.port.toFixed() // Error: Property 'port' does not exist
Now the types are precise but the typo went unchallenged until a call site failed, three files away.
as is worse than both
const config = {
host: 'localhost',
prot: 3000,
} as Config
This compiles. as is an assertion: you are telling the compiler to stop reasoning and take your word for it. The typo survives to runtime, and you have removed the one mechanism that would have caught it.
satisfies does both
const config = {
host: 'localhost',
port: 3000,
} satisfies Config
config.port.toFixed() // number
config.host.toUpperCase() // string
The expression is checked against Config, and the inferred type stays exactly what you wrote. Misspell port and you get an error at the object literal, where the mistake is.
Where it earns its keep
Route or event maps. Keys stay literal, so downstream code can be exhaustive:
const routes = {
home: '/',
profile: '/users/:id',
} satisfies Record<string, `/${string}`>
type RouteName = keyof typeof routes // 'home' | 'profile'
With an annotation, RouteName would have collapsed to string.
Theme tokens. You want the shape validated and the keys autocompleted:
const theme = {
primary: '#0af',
danger: '#f33',
} satisfies Record<string, `#${string}`>
Discriminated unions in arrays. Each element is checked, and narrowing still works afterwards:
const handlers = [
{ kind: 'click', fn: (e: MouseEvent) => {} },
{ kind: 'key', fn: (e: KeyboardEvent) => {} },
] satisfies Handler[]
When to keep the annotation
satisfies is not a replacement for annotations everywhere. Keep the annotation when you want the wider type — usually on a let, or on something that gets reassigned:
let state: 'idle' | 'loading' | 'done' = 'idle'
state = 'loading' // needs the annotation to be legal
With satisfies here, state would be inferred as 'idle' and the reassignment would fail.
The rule
Annotate when you want the wider type. Use
satisfieswhen you want the check without the widening. Useaswhen you have genuinely more information than the compiler — which is rarer than your codebase currently suggests.
Grep for as in your project. A good fraction of the hits are satisfies waiting to happen, and each one you convert is a check you get back.
These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.
Top comments (0)