Use satisfies to check a config object without widening its types

TypeScript 4.9 introduced satisfies. Here is a palette, and a type that says what a palette may contain:
type Colour = string | [r: number, g: number, b: number];
interface Palette {
[token: string]: Colour;
}
const annotated: Palette = {
ink: '#e2e8f0',
accent: [56, 189, 248],
};
That annotation does two jobs and most people only want one of them. It checks the object against Palette, which is the job you asked for. It also declares that annotated is a Palette, which throws away everything the compiler had just worked out about the object you wrote.
The easiest way to see what was thrown away is to ask the compiler to write it down. These are emitted declarations from the demo, not my summary of them — trimmed to the two that matter for this example; the full file also declares loose, typoCompiles, shouted, and the destructured r/g/b here, and annotatedRoute, satisfiedRoute, and frozen further down (quoted in their own sections below):
declare const annotated: Palette;
declare const satisfied: {
ink: string;
accent: [number, number, number];
};
What that costs in practice
Three things stop working on the annotated version, and each of them is a real line of code somebody writes later.
ink is no longer known to be a string. It is a Colour, so string methods are gone:
error TS2339: Property 'toUpperCase' does not exist on type 'Colour'.
accent is no longer a three-element tuple. Destructuring it into r, g and b still compiles, but each one comes out as string | number instead of number — you've lost the guarantee, even though anything that actually requires a number still catches the mismatch.
And the key set is gone. An index signature says every key is legal, so this compiles, with no such colour anywhere in the object — under the strict settings the demo uses. Turn on noUncheckedIndexedAccess and the same line becomes an error instead, because every indexed read is now Colour | undefined:
const oops: Colour = annotated.inkk; // fine, apparently
Under satisfies, the same typo is an ordinary error, and the compiler even guesses what you meant:
error TS2551: Property 'inkk' does not exist on type
'{ ink: string; accent: [number, number, number]; }'. Did you mean 'ink'?
The check is not weaker
The reasonable worry about satisfies is that it sounds like a softer annotation. It is not. Put a number where a Colour belongs and both forms report the same error, with the same code:
src/errors.ts(36,3): error TS2322: Type 'number' is not assignable to type 'Colour'.
src/errors.ts(41,3): error TS2322: Type 'number' is not assignable to type 'Colour'.
The first is the satisfies version, the second the annotated one. Identical. The demo asserts this rather than claiming it: the failing file carries an EXPECT TS2322 comment on each of those lines, and the check script compiles the file and compares the codes the compiler actually produced against the codes the comments predicted.
Unions widen the same way
The palette is the dramatic case because of the index signature, but the same thing happens to any literal you write against a union:
type Method = 'GET' | 'POST';
interface Route { path: string; method: Method }
const annotatedRoute: Route = { path: '/reports', method: 'GET' };
const satisfiedRoute = { path: '/reports', method: 'GET' } satisfies Route;
The compiler's own declarations again:
declare const annotatedRoute: Route;
declare const satisfiedRoute: {
path: string;
method: "GET";
};
If anything downstream needs to know that this particular route is a GET — a conditional type, an overload, a discriminated union — the annotated version has already forgotten.
It is not as const
satisfies preserves the inferred type. It does not make anything readonly, and the inferred type of a string property is still string, so this is legal:
const config = { ink: '#000' } satisfies Palette;
config.ink = '#fff'; // allowed
When you want both, use both, in that order:
const frozen = {
ink: '#e2e8f0',
accent: [56, 189, 248],
} as const satisfies Palette;
Now ink is the literal '#e2e8f0', and reassigning either property is an error — TS2540: Cannot assign to 'ink' because it is a read-only property. That readonly only reaches the property binding, though: accent's tuple type is still [56, 189, 248], not readonly [56, 189, 248], so frozen.accent.push(189) still compiles, though only 56, 189 or 248 type-check as the argument, since that's the tuple's own element union. Freezing the tuple's own contents for real would need Colour itself to declare a readonly tuple. as const narrows, satisfies checks, and neither one widens.
When the annotation is still right
Reach for the annotation when you genuinely want the wider type: a variable you intend to reassign with other palettes, a value you are about to pass somewhere that only knows about Palette anyway, an exported binding whose declared type is part of your public API and should not shift every time somebody adds a key.
For a config object that you write once and read everywhere — routes, palettes, feature flags, a table of handlers keyed by event name — satisfies is the one that leaves you something to work with.
The demo is at github.com/gdhami-net/satisfies-demo. npm run check compiles both halves and verifies the error codes; npm run types emits the declarations quoted above, so you can watch the type collapse for yourself rather than taking mine as read.