TypeScript satisfies vs annotation vs assertion
When you have an object literal and a type you want it to match, TypeScript gives you three ways to connect them: a type annotation, a type assertion, and the satisfies operator. They look similar, but they differ in how strictly they check the object and in what type the variable ends up with.
A small configuration object shows the difference:
type Config = {
environment: 'development' | 'production';
apiUrl: string;
retries: number;
};
const config = {
environment: 'development',
apiUrl: 'https://api.example.com',
retries: 3,
};
A type annotation attaches a type to a variable with a colon: const config: Config = { ... }. TypeScript checks the object literal against Config, reports missing properties, and applies excess property checking, the rule that an object literal cannot contain keys the target type does not declare. A value such as environment: 'prod' fails, because 'prod' is not one of the two allowed literal types. A literal type names one specific value, such as 'development'. The tradeoff is that the variable’s type becomes Config instead of the literal’s own shape. config.environment has type 'development' | 'production', so the specific value 'development' is widened to the broader union type. If a function expects the literal type 'development', you need a narrowing check before passing it. The TypeScript handbook on everyday types treats annotations as the standard way to declare what type a variable holds.
A type assertion, written const config = { ... } as Config, tells TypeScript to trust your claim about the value’s type. Assertions exist for cases where you know more than TypeScript can infer. The TypeScript handbook on everyday types notes that assertions are removed at compile time, do not affect runtime behavior, and should be used only when you have a reason TypeScript cannot know the type. TypeScript is lenient with assertions: it only checks that the two types overlap enough to make the assertion plausible, and it skips excess property checking. The same object with environment: 'prod' compiles when written with as, and config.environment is typed as 'development' | 'production' while actually holding 'prod' at runtime. The builder.io write-up on satisfies notes that as can even allow key-value pairs that do not exist in the target type.
Introduced in TypeScript 4.9, satisfies checks an object against a type while keeping the literal’s own inferred types. Write const config = { ... } satisfies Config. TypeScript checks the object against Config the same way an annotation does, so environment: 'prod' fails and excess keys are flagged. But the variable keeps those inferred types: config.environment is 'development', config.apiUrl is string, and config.retries is number. A function that expects the literal 'development' accepts config.environment directly, with no narrowing check. The release notes describe satisfies as a way to check that an expression matches a type without changing the resulting type of that expression. Nested objects behave the same way: satisfies checks the whole structure against the type while the inner values keep their inferred types.
All three checks happen at compile time only. None of them validates the object when the program runs, and none of them transforms the data. If the object comes from user input, an API response, or a JSON file, the compile-time checks say nothing about what is actually present at runtime; validating that data requires a runtime validator such as Zod. satisfies adds no runtime overhead because the operator is erased during compilation, and the same is true of annotations and assertions.
satisfies flags extra keys only on object literals. If the value is already stored in a variable or imported from JSON, satisfies checks that the value is compatible with the type but does not report extra keys, as shown in this Stack Overflow discussion.
Each check leaves the variable in a different state, and that state decides which one fits the situation.
A type annotation gives config the type Config. Downstream code sees config.environment as Config['environment'], so a function that requires the literal 'development' rejects the value until you narrow it. That is the right behavior when the declared type is the contract other modules should rely on: an exported config type, a function parameter, or a field in a class.
A type assertion (as Config) tells TypeScript to accept your claim that the value is a Config and skip the compatibility check for the asserted expression. The handbook describes assertions as useful when you know more about a value’s shape than TypeScript does, such as data parsed from a format that carries no type information. The tradeoff is that the assertion is unchecked: if the object does not actually match Config, TypeScript will not catch it.
satisfies gives the shape check of an annotation and the inferred types of an unannotated literal. Use it when the object lives in your own code and downstream code benefits from the narrower types, such as local configuration, lookup tables, or typed maps, while the check still catches typos, missing keys, and wrong value types.
satisfies does not replace assertions. Because it requires the object to actually match the type, it cannot override inference the way as does; when you need to widen a value or assert a shape TypeScript cannot verify, use an assertion.
An annotation fixes the type, an assertion overrides inference, and satisfies preserves it, so the choice depends on what downstream code should see.
Literal inference: why satisfies keeps green as "#00ff00"
The TypeScript 4.9 release notes use a palette example to show the failure satisfies fixes. The release notes define a Colors union and an RGB tuple type, then build a palette object that maps each color name to either a hex string or an RGB tuple:
type Colors = "red" | "green" | "blue";
type RGB = [red: number, green: number, blue: number];
const palette: Record<Colors, string | RGB> = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255]
};
const greenNormalized = palette.green.toUpperCase();
The last line fails to compile. The annotation Record<Colors, string | RGB> types every property as the union string | RGB, so palette.green is string | RGB, and a tuple has no .toUpperCase() method.
The fix in the release notes replaces the annotation with satisfies:
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255]
} satisfies Record<Colors, string | RGB>;
const greenNormalized = palette.green.toUpperCase();
The example compiles because satisfies checks the object against the type and then leaves the expression’s own inferred type in place. palette.green keeps the type TypeScript inferred from the literal rather than the string | RGB union.
The inferred type is string rather than "#00ff00" because of literal inference. A literal type names one specific value, such as the exact string "#00ff00". The TypeScript handbook on everyday types explains that during inference TypeScript widens literal types: a const variable gets the literal type 'hello', while a let variable or an object property gets the general type string. A plain object literal { green: "#00ff00" } therefore infers green as string.
Add as const to pin the exact literal. The handbook calls as const a type assertion that tells TypeScript to infer the most specific type possible for a value. With satisfies, you get both the literal type and the validation:
const palette = {
red: "#ff0000",
green: "#00ff00",
blue: "#0000ff"
} as const satisfies Record<Colors, string>;
palette.green; // type: "#00ff00"
as const pins green to "#00ff00", and satisfies still verifies that every Colors key is present and every value is a string. A typo such as grene or a missing blue key fails the check. palette.green keeps the exact literal type, so string methods like .toUpperCase() are available.
Nested values and excess properties: what satisfies checks and what it ignores
satisfies checks the whole object, not just the top level. When you write config satisfies Config, TypeScript verifies that every required property exists, that each present value matches the declared type, and that nested objects conform to their nested types. The TypeScript 4.9 release notes show the missing-property side of the check: a typo such as bleu in an object that must contain red, green, and blue fails because a required key is absent.
Nested objects are checked the same way. If Config declares logging: { level: 'info' | 'debug'; file?: string }, then satisfies checks that the nested logging object has level set to one of those two strings and that file, when present, is a string. The nested values keep the types TypeScript infers for them, so config.logging.level stays 'info' rather than widening to the declared 'info' | 'debug' union. Optional nested properties are checked only when present; satisfies does not add them to the inferred type, so a key you did not write is not accessible on the result.
Excess properties are where the check has a visible limit. When a literal is assigned to a typed target, properties the target does not declare are reported as errors. satisfies applies the same rule to an inline object literal, including nested literals, so an extra key such as debugMode on a Config literal fails to compile even though the object would be assignable to Config if it arrived through a variable.
That condition is the practical limitation. Excess property checking applies to fresh object literals, not to every assignability check. satisfies performs an assignability check and does not create an assignment context, so the extra-key error appears only when the checked expression is an object literal written in place. An object that comes from a variable, a function return, or imported JSON passes satisfies with extra properties undetected. If you need to reject extra keys on data that did not originate as an inline literal, satisfies will not do it; that requires a runtime validator or a type-level key check.
To allow extra properties deliberately, widen the target with an index signature: config satisfies Config & Record<string, unknown>. The required-property and value checks still apply, but TypeScript accepts additional keys.
Missing required properties fail the same way. The TypeScript 4.9 release notes demonstrate this with a palette object whose typo bleu fails against Record<Colors, string | RGB>; the notes describe satisfies as able to verify that an object has all the keys of a type. A plain interface behaves the same way. If Config declares apiUrl: string, then { environment: 'development' } satisfies Config errors because apiUrl is absent. satisfies validates the whole declared shape, including properties you did not write.
The index-signature widening leaves the extra keys in the inferred type. After const config = { environment: 'development', apiUrl: 'https://api.example.com', debugMode: true } satisfies Config & Record<string, unknown>, config.debugMode is accessible and typed boolean. The widening only silences the excess-property error; it does not remove the property from the result.
Compile-time guarantees only: why satisfies cannot validate runtime data
The satisfies operator does its entire job before your code runs. When TypeScript compiles config satisfies Config, it checks the object literal against the Config type, then removes the operator from the emitted JavaScript. The runtime value is the plain object literal you wrote, with no validation code added. That is the source of the operator’s zero runtime overhead, and also the source of its limit.
TypeScript’s documentation draws the same line for the related mechanisms. Type assertions such as as Config are removed at compile time, so they perform no runtime checking. The same erasure applies to satisfies: like a type annotation or a type assertion, it exists only in the type system. None of them leaves anything behind in the JavaScript that runs.
The practical consequence is that these checks validate the source code, not the data your program receives. If Config describes an object that arrives from a JSON file, an API response, or user input, the compiler cannot know what the actual value will be at runtime. The object literal in your source passes satisfies, but the value that comes back from JSON.parse or a network request carries no such guarantee. If that payload is malformed, nothing in the compiled output detects it.
To validate data whose shape is unknown at compile time, you need a runtime check. The standard TypeScript mechanism is a user-defined type guard: a function that inspects a value and returns a boolean. Its return type, like value is Config, tells TypeScript the value matches the type when the function returns true. You call the guard on the incoming data, and only then treat the value as Config. For larger or nested shapes, schema libraries such as Zod generate these guards from a schema and derive the TypeScript type from the same definition.
That boundary is the rule to keep in mind: compile-time checks protect against mistakes in the code you write, and runtime validation protects against mistakes in the data you receive. satisfies handles only the first job. It checks the shape of the object literal while preserving the inferred types, and it does so without any runtime cost, but it does not make your program safe against malformed input.
How to choose between annotation, assertion, and satisfies
When you connect an object literal to a type, the mechanism you pick decides what downstream code sees. A type annotation fixes the variable’s type to the declared type. A type assertion overrides TypeScript’s inference with your own claim. The satisfies operator, introduced in TypeScript 4.9, checks the object against the type while leaving the inferred type in place. The three checks answer different questions, so the right one depends on what the rest of your code needs.
Use a type annotation when the declared type is the contract. With const config: Config, TypeScript checks the literal against Config and applies excess property checking. It gives the variable exactly the Config type. Downstream code sees config.environment as the declared type, such as string or a union of allowed values, not the literal 'development'. That behavior is correct when callers should treat the object as a Config and nothing narrower.
Use satisfies when the literal’s precise types matter downstream. const config = {...} satisfies Config runs the same shape check as the annotation, covering required keys, value types, and nested objects, but config.environment keeps the inferred type 'development'. That precision matters when code branches on specific values or when editor autocomplete should show the keys you actually wrote rather than every key the type allows.
Use a type assertion only when you must override what TypeScript can verify. as Config skips excess property checking and requires only that the object plausibly overlaps with Config, so it can hide typos and allow keys that do not exist in the type. Reserve assertions for when you have information the compiler does not, such as data you have already validated at runtime, and accept that the compiler will not check the shape for you.
All three checks share two limits. They run at compile time only: TypeScript erases annotations, assertions, and satisfies from the emitted JavaScript, so none of them validates data that arrives at runtime from an API response, a JSON file, or user input. A runtime validator such as Zod handles that job. satisfies also preserves inference, but TypeScript’s normal literal inference widens object property values from 'development' to string, as the everyday types handbook explains. Add as const when you need the exact literal: as const satisfies Config gives both the shape check and the precise types.
satisfies flags excess properties only on object literals. If the object is already a variable or comes from imported JSON, the excess property check does not run, so a stray key can pass silently. Keep the literal next to the satisfies operator when you want that check.
When to use satisfies, annotation, or assertion
- Use
satisfieswhen you want a shape check without losing inferred types:config satisfies Configvalidates the object against the type the way an annotation does, while keepingconfig.environmentas'development'rather than widening it tostring, as described in the original operator proposal. - Add
as constwhen you need exact literal values: literal widening turns'development'intostring, so combineas const satisfies Configto pin the exact literal and still check the shape. The Builder.io walkthrough demonstrates the pattern. - Treat
satisfiesas a compile-time check only: TypeScript erases the operator from the emitted JavaScript, so it validates source code, not API responses, JSON files, or user input; add a runtime validator such as Zod for data that arrives at runtime. - Remember that excess property checking applies only to object literals:
satisfiesflags extra keys on a literal you write, but not on variables or imported JSON, so unintended properties in those sources can slip through undetected. - Reserve type assertions (
as Config) for cases where you hold information the compiler does not: assertions skip excess property checking and can allow nonexistent keys, so prefersatisfiesor an annotation for ordinary object definitions.