TypeScript Guide EP14: The Satisfies Operator
Video
Watch the full video on YouTube:
Introduction
In this fourteenth chapter of our TypeScript series, we meet satisfies, the instruction introduced in TypeScript 4.9 that gives us more flexibility when typing variables and constants.
We already saw type assertion (telling the compiler “I know better what type this is”) and type narrowing (narrowing a type by validating at compile and/or runtime). satisfies sits at a very convenient middle ground: it checks that a value conforms to a type without forcing an annotation that loses concrete information about the value.
The Problem with Traditional Typing
Imagine an API that consumes from two databases: MongoDB (string id, a uid) and Postgres (serial auto-increment id).
type Employee = {
id: string | number
name: string
}
// Traditional annotation
const employee: Employee = {
id: "1",
name: "Angel"
}
It works, but when typing employee.id. we don’t get autocomplete for string or number methods. The constant’s type was “flattened” to Employee, so TypeScript no longer knows the concrete value is a string.
The conventional solution was type narrowing:
if (typeof employee.id === "string") {
// now it autocompletes string methods
employee.id.toUpperCase()
}
How Satisfies Works
With satisfies we no longer annotate before the equals sign; we add the instruction at the end of the value:
const employee = {
id: "1",
name: "Angel"
} satisfies Employee
We’re telling it: “this object satisfies, it really implements this particular type”. If the answer is yes, it behaves like traditional typing: name can’t be a number.
Key Difference
| Traditional annotation | satisfies |
|
|---|---|---|
| Checks type conformance | Yes | Yes |
| Preserves the concrete value type | No | Yes |
| Autocompletes the real value | No | Yes |
// With satisfies: we keep the literal value
employee.id.toUpperCase() // ok: it's a string
// If we change to id: 1 (no quotes)
employee.id.toFixed(2) // ok: it's a number
// But it still validates Employee:
const bad = {
id: true, // error: boolean doesn't satisfy string | number
name: "Angel"
} satisfies Employee
We type while preserving our object’s integrity.
Advanced Case: Routes with Record
The most powerful real-world use appears when we need to preserve the structure (schema) of our data.
Route type definition
type RouteConfig = {
path: string
isRequired: boolean | ((employee: Employee) => boolean)
}
The Problem with Traditional Annotation
const routes: Record<string, RouteConfig> = {
"users-list": {
path: "/users",
isRequired: true
},
"user-dashboard": {
path: "users/:id",
isRequired: (employee) => Number(employee.id) === 2
}
}
When typing routes. it doesn’t know which keys exist: I can type anything (routes.caramelo). Even though Record knows RouteConfig’s attributes, it doesn’t know the literal keys - that’s why the LSP forces optional chaining or marks non-existent elements as optional.
The Solution with Satisfies
We remove the annotation and use satisfies at the end:
const routes = {
"users-list": {
path: "/users",
isRequired: true
},
"user-dashboard": {
path: "users/:id",
isRequired: (employee) => Number(employee.id) === 2
}
} satisfies Record<string, RouteConfig>
Syntactically there’s a difference; structurally it looks the same. But now routes.:
- Knows which elements exist (
users-list,user-dashboard) - Knows the exact signature of each one (path: string, isRequired: boolean or function)
- Any new key is validated against
Record<string, RouteConfig>
routes["users-list"].isRequired
// autocomplete: boolean | ((employee: Employee) => boolean)
routes["user-dashboard"].isRequired({ id: 2, name: "Milo" })
// returns boolean according to the function
We interact with the object knowing exactly which structures it has without losing value inference.
When to Use Satisfies (and When Not)
satisfies didn’t arrive to replace traditional typing. It’s a tool for specific situations:
- Use
satisfieswhen you need to preserve the data schema: literal keys, exact shapes, autocomplete for configuration structures (routes, feature flags, form schemas) - Use
satisfieswhen the type is a union and you want to verify the value conforms to a particular type without losing the concrete type - Use traditional typing (
const x: Type = ...) when you only need the type guarantee and don’t care about losing the value’s concrete shape - Use type narrowing (preferred) or type assertion when you need safe access to attributes and values of a concrete union member
Best Practices
- Prefer
satisfiesover annotatingRecord<string, T>when the object has known fixed keys: you keep the literal keys - Combine
satisfieswith literal types for configuration that must be compile-time validated (route paths, feature keys) - Remember the order: value first,
satisfies Typeat the end (it’s not an annotation on the declaration) - For runtime unions,
satisfiesvalidates at compile time; you still need type narrowing for safe runtime access - Don’t use it as a universal replacement: if you don’t need the concrete shape, traditional typing is simpler
Conclusion
In this episode we saw that satisfies checks that a value conforms to a type while preserving the concrete data type. With the Employee example we compared traditional typing (which flattens the shape) against satisfies (which keeps string or number autocomplete), and with RouteConfig we saw how to preserve literal keys of a Record without sacrificing schema validation.
satisfies is ideal when we need to preserve the structure of our data; for other scenarios, traditional typing and type narrowing remain the foundation.
If the explanation wasn’t clear or you have more advanced use cases, leave them in the comments.
Repository
💻 Repository: https://github.com/radagva/ts-example
