When you write an object literal and want TypeScript to check that it fits a known shape, the two familiar tools each force a tradeoff. With a type annotation, const theme: Theme = {...}, TypeScript widens every property to the declared type, so the exact values you wrote stop being part of the type. With a type assertion, const theme = {...} as Theme, you ask the compiler to trust you, and it skips most of the checking.
The satisfies operator, introduced in TypeScript 4.9, validates that an expression matches a type without changing the type the compiler infers from the expression. TypeScript infers that type from the values themselves, before any annotation widens it. Literal types, which name one exact value such as '#3b82f6' or 8, are the detail that usually gets lost in that widening.
Configuration objects, feature-flag tables, and handler maps for typed events, where each event type gets its own function, are where satisfies is most useful. It also has clear limits: data the code mutates and objects full of functions are better served by the standard tools.
Why annotations and assertions fall short
Say a theme object must contain three properties, each of which may be absent at runtime:
type Theme = {
primary: string | null;
accent: string | null;
radius: number | null;
};
With an annotation, TypeScript checks the shape and then widens:
const theme: Theme = {
primary: '#3b82f6',
accent: '#f59e0b',
radius: 8,
};
theme.primary.toUpperCase(); // error: 'theme.primary' is possibly 'null'
The compiler verified that every property exists and has a compatible value, then treated each one as string | null or number | null. If you call .toUpperCase() on theme.primary, the compiler rejects the call because it cannot rule out null. The specific values you wrote are gone from the type, so every consumer of theme must narrow or guard.
An assertion takes the opposite risk:
const theme = {
primary: '#3b82f6',
accent: '#f59e0b',
radius: 8,
typography: 'system-ui',
} as Theme;
as tells the compiler to trust the asserted type and skips the checks a normal assignment runs. The stray typography key passes silently; the typo shows up only later, when some other line reads theme.typography and the property does not exist on the type. Assertions have a place when you know more than the compiler does, but they hide bugs when they cover up a mismatch instead of reporting it.
satisfies runs the same checks as an annotation, then keeps the type the compiler inferred from the values.
How satisfies checks the shape without widening the type
const theme = {
primary: '#3b82f6',
accent: '#f59e0b',
radius: 8,
} satisfies Theme;
The check behaves like the annotation: every property must exist, and its value must be assignable to the declared type. radius: '8' fails, a missing accent fails, and an extra typography property fails too, because excess property checking still applies. Excess property checking is the compiler’s rule that an object literal cannot contain properties the type does not declare, which is how a typo like typography gets caught.
The type of theme is the inferred one rather than Theme: satisfies validates the expression without changing its inferred type. Because the compiler widens the literal '#3b82f6' to string during inference, theme.primary is string, not string | null; theme.radius is number, not number | null. Consumers of theme no longer need null guards, and the compiler still caught every shape mistake. To keep the literal '#3b82f6' instead of the widened string, add as const (covered in the next section).
When a property does fail, the error points at that property. The compiler reports the specific property that does not match and leaves the rest of the object alone, so you fix the typo rather than untangle a whole-object error.
satisfies exists only during type checking. The compiler erases it, and the value that reaches runtime is exactly the object literal you wrote, with no coercion or transformation.
Combining satisfies with as const
Static configuration often needs the exact values preserved. Combining satisfies with as const gives an immutable object and a schema check in one expression:
type ThemeTokens = {
primary: string;
accent: string;
radius: number;
};
const theme = {
primary: '#3b82f6',
accent: '#f59e0b',
radius: 8,
} as const satisfies ThemeTokens;
as const narrows each property to its exact literal value and marks the object deeply readonly. Then satisfies checks the object against ThemeTokens, so theme.primary has type '#3b82f6', theme.radius has type 8, and an assignment like theme.primary = '#000000' fails because the property is readonly.
The order of the two operators matters. Write as const first; satisfies ThemeTokens as const is an error.
The literal and readonly effects are deep. A nested host: 'localhost' inside a server config stays 'localhost' with as const; with plain satisfies, the compiler leaves the widened string. If literal types inside nested objects matter, as const keeps them literal.
If you need an as assertion to make an as const object typecheck, the object probably should not use as const; the assertion undoes the literal and readonly guarantees that the pattern is meant to provide. Because those guarantees also block updates, this pattern is only for values that never change.
Enforcing exhaustive maps and discriminated unions
satisfies is most useful with Record types over a union of keys. The union makes the key set explicit, and the compiler enforces it:
type FeatureFlag = 'darkMode' | 'betaSearch' | 'newCheckout';
const flags = {
darkMode: true,
betaSearch: false,
newCheckout: true,
} satisfies Record<FeatureFlag, boolean>;
Record<FeatureFlag, boolean> requires a boolean property for every name in the union. Add 'voiceNotes' to the union, and the compiler reports the missing key in flags. Exhaustive coverage becomes a compile-time error instead of a runtime undefined. The union also catches typos that a Record<string, boolean> would let through. A misspelled darkmode key becomes a separate key, so the compiler rejects it.
The same pattern covers route tables, translation catalogs, and color maps, where satisfies shows up most often in real code:
type Page = 'home' | 'about' | 'contact';
const routes = {
home: '/',
about: '/about',
contact: '/contact',
} satisfies Record<Page, string>;
When only a subset of keys is required, Partial<Record<Page, string>> constrains the allowed keys without demanding all of them.
The same exhaustive check types a handler map for a discriminated union. A discriminated union is a set of object types that share one property, the discriminant, whose literal value identifies which member an object is:
type Event =
| { type: 'created'; id: number }
| { type: 'renamed'; id: number; name: string }
| { type: 'deleted'; id: number };
const handlers = {
created: ({ id }: { id: number }) => id,
renamed: ({ name }: { id: number; name: string }) => name,
deleted: ({ id }: { id: number }) => id,
} satisfies {
[K in Event['type']]: (event: Extract<Event, { type: K }>) => unknown;
};
The type on the right requires a property for every discriminant value K, and each property’s function must accept the member of the union with that discriminant. Extract<Event, { type: 'created' }> picks the member whose type is 'created'. Add { type: 'archived'; id: number; archivedBy: string } to the union, and the compiler requires an archived key in handlers. If you change a handler’s parameter to the wrong payload, the error points at that property.
Write each handler against its specific event member, and the body uses event.id or event.name directly. No runtime type guard and no switch narrowing is needed inside the handler. The same mechanism types a state-machine transition table, where each cell keeps its literal value under as const satisfies Record<Status, Record<EventType, Status | null>>, so you can derive the transition types from the table itself.
Where satisfies does not help
When the checked object contains functions, satisfies does not supply parameter types for them. Leave a parameter unannotated, and the compiler reports it as implicitly any; you have to write the parameter types yourself:
const api = {
create: (name) => ({ name }), // error: 'name' implicitly has an 'any' type
} satisfies { create: (name: string) => { name: string } };
Annotate the parameter as (name: string) => ..., and the check works as intended.
The as const satisfies pattern is for static values. For configuration whose values are genuinely variable strings or numeric ranges, pinning literals adds noise without benefit. And for form state, user preferences, or other mutable data, the deep readonly blocks legitimate changes; use plain satisfies or an annotation there.
satisfies answers one question: I want a shape check, and I also want the compiler to keep the narrower inferred types. When either half of that is missing, the standard tools are better. Most of the time you should let TypeScript infer the type or write an explicit annotation; satisfies is not the default. If you reach for it on every object literal, you add extra syntax without adding safety.