satisfies checks whether an expression is assignable to a target type without simply replacing the expression’s resulting type with that target.
It works only at compile time, does not validate external data, and does not preserve every property as a literal type.
Describe static tables with finite key sets, validate data at runtime boundaries, and add as const only when readonly literal precision is intentional.
What it is and why it exists
The satisfies operator checks whether the type of its left-hand expression is assignable to its right-hand target type. After a successful check, the variable exposes the expression’s specific resulting type instead of uniformly exposing the target. It fits configuration objects, route tables, and command registries where you want to check the whole shape and still use details of individual entries.
A type annotation answers “what type should this variable expose?” It binds the declaration to the written contract, so later reads and assignments are handled through that contract. satisfies asks a narrower question: “is this expression compatible with the contract?”
A type assertion instead asks the checker to accept the developer’s judgment. An assertion can bypass a compatibility error that satisfies cannot force into correctness. Prefer a check when proving the shape of a source-code constant; at a network, file, or environment boundary, neither construct replaces runtime validation.
The distinction appears most clearly in heterogeneous objects. A palette might contain strings and RGB tuples; annotating the whole variable as Record<string, string | RGB> makes every property read return that union. satisfies can check every entry while known string entries retain string operations and array entries retain tuple behavior.
How it works
value satisfies Target is an expression. The compiler first uses Target as context for the left-hand expression, then checks whether the resulting left-hand type is assignable to Target. The final expression type comes from that inference rather than being replaced wholesale by Target.
That is why “satisfies never affects inference” is also too broad. The target participates in contextual typing for object literals, array literals, and callback parameters. For example, a target property of "GET" | "POST" can preserve one method as "GET", while an ordinary string target usually still gives a mutable object property the type string.
One check breaks down into four steps:
- Read the target type and establish context for properties, index signatures, and callbacks.
- Infer the left-hand expression within that context.
- Check compatibility using TypeScript’s structural and assignability rules.
- Keep the inferred expression type and remove
satisfies Targetwhen emitting JavaScript.
Structural typing means compatibility depends mainly on member shape, not declaration names. Required properties must exist and property values must be compatible; a direct object literal also receives an excess property check. If the target contains an open index signature such as Record<string, Entry>, every string key is valid, so the check cannot find a misspelled key.
satisfies does not create properties, freeze objects, convert values, or emit runtime code. It also cannot make an any produced by JSON.parse() safe, because any already bypasses most static checking. Keep external values as unknown until real runtime checks establish the domain type.
Examples
The four examples build up a static registry. They cover per-entry inference, heterogeneous values, composition with as const, and the boundary between static checking and runtime validation.
Check a route registry
An annotation is useful for fixing a public contract, but every property read then sees only that contract. satisfies still checks the finite key set while preserving the specific "GET" type of health.method.
type Method = "GET" | "POST";
type Route = { path: string; method: Method };
const annotated: Record<string, Route> = {
health: { path: "/health", method: "GET" },
};
const routes = {
health: { path: "/health", method: "GET" },
createUser: { path: "/users", method: "POST" },
} satisfies Record<"health" | "createUser", Route>;
function acceptGet(method: "GET"): string {
return method;
}
console.log(acceptGet(routes.health.method));
console.log(Object.keys(routes).join(","));
console.log(annotated.health.method);GET
health,createUser
GETacceptGet(routes.health.method) type-checks because the target union supplies context for that property and the result retains the selected union member. annotated.health.method is also GET at runtime, but its static type is the full Method, so it cannot be passed directly to a function that accepts only "GET".
Preserve operations on heterogeneous entries
The target permits either a string or an RGB tuple. The checker rejects an array with the wrong length while known entries retain the operations that apply to each one.
type RGB = readonly [number, number, number];
type ColorValue = string | RGB;
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies Record<"red" | "green" | "blue", ColorValue>;
const firstChannel: number = palette.red[0];
const uppercaseGreen = palette.green.toUpperCase();
console.log(firstChannel);
console.log(uppercaseGreen);255
#00FF00Here palette.red is inferred as a three-element array under the target union’s context, while palette.green can call string methods directly. Do not read this as preservation of every original literal: palette.green is string, not "#00ff00".
The key set matters too. Changing the target to Record<string, ColorValue> would still check the values, but a spelling such as gren would become a valid new key. When the program owns the key set, use a literal union, a mapped type, or keyof from an existing object.
Combine as const with satisfies
When callers need exact values and readonly properties, apply a const assertion first and then check the shape. Arrays in the target must also be declared readonly; otherwise a readonly tuple is not assignable to a mutable array.
type Role = "viewer" | "editor";
type Command = {
label: string;
roles: readonly Role[];
};
const commands = {
publish: {
label: "Publish",
roles: ["editor"],
},
preview: {
label: "Preview",
roles: ["viewer", "editor"],
},
} as const satisfies Record<"publish" | "preview", Command>;
type CommandName = keyof typeof commands;
function canRun(command: CommandName, role: Role): boolean {
return commands[command].roles.some((allowed) => allowed === role);
}
console.log(canRun("publish", "editor"));
console.log(commands.preview.roles.join(","));
console.log(Object.isFrozen(commands));true
viewer,editor
falsekeyof typeof commands derives "publish" | "preview" from the real object, avoiding a separate list of names. The role arrays retain readonly tuple precision, so allowed inside some() can only be a role actually declared for that command.
The last line shows that as const is only a static assertion. It does not call Object.freeze(), and satisfies adds no runtime protection either. If runtime immutability is part of the contract, use runtime mechanisms such as freezing, encapsulation, or copying.
Perform real validation at a data boundary
A source-code default is a good candidate for satisfies; a parsed external value should remain unknown first. A type guard must inspect the container, discriminant, and field types rather than merely declaring a predicate return type.
type Settings = {
mode: "safe" | "fast";
retries: number;
};
const defaults = {
mode: "safe",
retries: 2,
} satisfies Settings;
function isSettings(value: unknown): value is Settings {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
(candidate.mode === "safe" || candidate.mode === "fast") &&
typeof candidate.retries === "number"
);
}
const external: unknown = JSON.parse('{"mode":"fast","retries":"3"}');
console.log(isSettings(defaults));
console.log(isSettings(external));true
falseThe external JSON has a string-valued retries, so the validator returns false. Writing JSON.parse(raw) satisfies Settings instead usually compiles because the left side is any, yet it checks no field. That spelling can create a stronger illusion of validation than an obvious assertion.
The example’s as Record<string, unknown> is used only to read candidate properties after proving that the value is an object. Every domain field is then checked explicitly; the local assertion does not promote the candidate directly to Settings.
Pitfalls
Fix: accept external data as unknown and use a parser, schema validator, or field-by-field type guard. Only the successful validation branch should construct a domain value; satisfies may check the validator’s own static declarations.
Fix: use an annotation when the object must expose the complete contract or gain optional properties later. Use satisfies when checking the current declaration while retaining its exact set of known keys.
Fix: decide what precision callers actually need. A literal-union target can retain the selected member; use as const satisfies Target when the whole object should be exact and readonly, but do not over-narrow mutable state merely to display narrow types.
Fix: express legal keys as a literal union such as Record<RouteName, Handler>. When deriving them from existing data, use keyof typeof source so one declaration remains the source of truth.
Fix: remove the assertion and correct the left-hand value or target contract. For a boundary the static system cannot express, isolate the assertion in a small helper and prove its preconditions with runtime checks and tests.
Fix: annotate the variable or property with the intended mutable type when state must move among several valid values. satisfies is a better fit for tables that remain stable and feed derived keys, unions, or callback signatures.
Contextual typing shapes inference
Type inference does not inspect a literal in isolation. The expected type at an expression’s position can also shape parameters, arrays, and object properties; this is contextual typing. The right side of satisfies supplies such a context.
In an ordinary const plain = { mode: "safe" }, plain.mode usually widens to string because object properties remain mutable. With a target of { mode: "safe" | "fast" }, satisfies supplies a literal-union context and the result can retain "safe". If the target is merely { mode: string }, there is no narrow union member to select and the result is still normally string.
Arrays also use target context. When a target branch contains a fixed-length tuple, an array literal can be inferred as a tuple and preserve length information. If the target contains only number[], the array remains an ordinary array; satisfies does not automatically make every array a tuple.
Callback parameters can receive types from the target too. If a registry target requires (event: Event) => void, an unannotated callback parameter is inferred as Event. The final object still has the inferred signature of each callback, but function-parameter variance rules are not disabled.
This mechanism explains why “checks without ever changing the type” is misleading. More precisely, the target participates in contextual inference of the left side, and the completed check does not overwrite the inferred result with the entire target type. Review the type displayed by the compiler instead of guessing from the slogan.
Exact keys and assignability
satisfies uses ordinary assignability rules; it does not introduce a new “exact object type.” A direct object literal receives an excess property check against a target without an index signature, so Record<"read" | "write", Handler> rejects wirte. If the same object is first stored in another variable and then checked, general structural rules may allow extra members.
Required-key checking comes from the mapped type itself. Record<"read" | "write", Handler> expands to two required properties, and omitting either one fails. Record<string, Handler> only says “any string property that exists must be a Handler”; it requires no specific key.
Derive a finite key union from the most reliable source. If the command object is authoritative, derive names with keyof typeof commands; if a protocol defines names first, use Record<CommandName, Command> to check the object. Avoid hand-maintaining separate object keys, a union, and a validation array.
An excess property check is not a runtime allowlist. Even after a source object passes an exact-key check, JavaScript can add properties at runtime and external JSON can contain unknown fields. A runtime parser must implement the policy explicitly when unknown fields should be rejected.
Optional properties retain their meaning
An optional property in the target means a compatible value may omit it. When the left side does so, satisfies accepts the object, but the resulting type still contains only the properties actually declared. The target does not invent static availability for "cache" in config or config.cache.
When the left side includes an optional property, its value must obey assignability under the current compiler options. With exactOptionalPropertyTypes, option?: string means a present property must be a string; it does not automatically include undefined. With the option disabled, an explicit undefined has broader compatibility.
Libraries and applications should verify examples and declarations under their own tsconfig. satisfies does not isolate code from options such as strictFunctionTypes, noUncheckedIndexedAccess, or exactOptionalPropertyTypes. Run tsc with the target project’s real configuration whenever generated code is copied in.
When a property will be added later, an annotation usually expresses the intent better. For example, declare const config: Config = defaults before updating config.cache; the exposed contract is clear and permits changes defined by the target. Adding properties through assertions after satisfies merely fights the earlier choice to preserve an exact resulting type.
as const satisfies is ordered
The expression value as const satisfies Target applies the const assertion to the literal first, then checks whether the readonly, narrowed result is assignable to Target. Object properties become readonly, arrays become readonly tuples, and primitive values retain literal types where possible. The target must accept that readonly shape.
The order fits static constant tables but not every configuration. If a downstream function owns an array and sorts or appends to it, passing a readonly tuple should fail; changing the target array to readonly is accurate only for a consumer that truly does not mutate it, not a way to conceal mutable requirements.
Both operators disappear from emitted JavaScript. The runtime value remains an ordinary object with the same identity it would have without the type syntax. A successful compiler check proves only that the checked source expression is statically compatible with the target, not that the object can never change afterward.
Exported constants also require attention to declaration output. Extremely narrow inference can make an emitted .d.ts expose many concrete properties; that may be deliberate, or it may turn implementation detail into public API. When a library boundary needs a stable contract, annotate the export and use satisfies internally to check a more specific implementation table.
Choose syntax by contract and mutability
A type annotation, satisfies, as const, and a type assertion are not four stylistic spellings of the same operation.
They control the exposed contract, compatibility checking, readonly literal inference, and checker trust respectively, so choose according to what the code must do next.
A type annotation fixes the exposed type
An annotation is usually clearest when a variable must move among several valid states.
let mode: "safe" | "fast" = "safe" states that a later assignment may select the other union member instead of treating the initializer as permanent state.
Exported function parameters, results, and objects also often need annotations because maintainers do not want implementation changes to alter consumer-visible types silently. Widening from an annotation is not lost information in this case; it is a deliberate choice to stabilize the API.
satisfies checks the current expression
A static table is commonly declared once and then used to derive names, discriminants, or callback types. Retaining member information from the current expression has practical value there, while the target rejects missing entries and incompatible values.
The target should not be broader than the real contract.
If consumers support only two commands but the implementation is checked with Record<string, Command>, the result claims a false open capability rather than delivering a more flexible design.
as const requests readonly precision
as const fits protocol constants, test vectors, and lookup tables that will not be modified.
It recursively marks properties in the literal expression as readonly, but it does not deeply freeze existing objects referenced by the value.
When only one discriminant needs a narrow type, give that field a more specific context instead of freezing the entire object’s static shape. That local design usually interoperates more easily with code that requires mutable collections.
A type assertion records external proof
A type assertion should appear only where the developer holds evidence that the compiler cannot express. A typical location is a small adapter after runtime checks, not the point where unvalidated data first enters the system.
The evidence source, precondition, and failure policy should be visible near the assertion. If its only explanation is “the data should look like this,” there is not yet enough proof.
| Syntax | Primary purpose | Resulting type | Runtime behavior |
|---|---|---|---|
const value: Target = expression | Fix the exposed contract | Target | No added validation |
const value = expression satisfies Target | Check assignability | Contextually inferred result | No added validation |
expression as const | Request readonly literal precision | Narrow readonly result | Does not freeze the object |
expression as Target | Ask the checker to trust an assertion | Target | Neither validates nor converts |
Lock static guarantees with type tests
The value of satisfies exists in compiler behavior, which executing JavaScript cannot verify.
Alongside running examples, run tsc --noEmit and express important accepted and rejected cases as type tests.
Positive tests prove usable capabilities
A positive test should consume specific information retained after the check, such as passing routes.health.method to a function that accepts only "GET".
That is more informative than declaring the object alone because a target that accidentally widens will fail at the use site.
You can also build a name union with keyof typeof registry and require functions to accept only that union.
After adding or removing a registry entry, type tests reveal whether the derived API changes as intended.
Negative tests prove errors stay rejected
Use @ts-expect-error on an intentional error to make “this must fail” an executable assertion.
If a future refactor stops producing the diagnostic, TypeScript reports that the directive is unused.
A finite registry should test at least one missing key, one extra key, and one invalid value.
A mutable configuration should also test valid reassignment, preventing a switch from an annotation to satisfies from leaving an unintentionally narrow type.
Compiler configuration is test input
Type tests must use the TypeScript version and tsconfig supported by the project.
Temporary editor inference cannot replace CI because the editor may select another workspace version or different configuration.
Record at least these conditions when verifying generated code:
- The exact TypeScript version, not merely “latest.”
- Whether
strictandexactOptionalPropertyTypesare enabled. - The entry file and
tsconfigused by the type tests. - The expected process status for successful and intentionally failing commands.
These records distinguish language changes, configuration differences, and code regressions.
Saving only runtime snapshots misses the most important static guarantee of satisfies.
Layer registries and external data
A mature system commonly has both static registries and dynamic input, and they should not share one notion of “validation.” TypeScript checks the source registry, a runtime parser checks external names and payloads, and domain logic receives only validated results.
The static layer owns implementations
A handler object can be checked with a finite key union and satisfies.
This ensures that every protocol command has an implementation while retaining each handler’s particular parameter and result types.
Deriving keyof from that object produces the names the implementation really owns.
Do not assert that Object.keys() is an arbitrary union unless the object’s runtime construction path also guarantees there are no extra keys.
The boundary layer owns parsing
A parser accepts unknown, checks that the input is an object, and then validates the command name and corresponding payload.
It can return a discriminated union so later control flow narrows the entire request by name.
Runtime membership checks should come from data synchronized with the static union, such as an as const name array or schema definition.
A type predicate with a return annotation but no real comparison remains another unproved assertion.
The domain layer relies on established invariants
Domain dispatch need not repeat every primitive field check, but it should retain a runtime failure for unreachable branches. Even if a static union appears exhaustive, old clients, JavaScript callers, or a bad assertion can still supply an unknown name.
This layering gives satisfies one clear role: checking declarations owned by the source code, not input sanitization, security validation, or data migration.
When generated code collapses all three layers into one assertion, restore the boundary before discussing inference precision.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug