- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have a config object. You want the compiler to check it
against a known shape, so you reach for a type annotation:
type RouteConfig = Record<string, string>;
const routes: RouteConfig = {
home: "/",
users: "/users",
billing: "/billing",
};
The check works. Add a route whose value is a number and the
compiler complains. Good. Then you try to read a route by its
key and the editor goes quiet:
routes.home;
// string
routes.dashboard;
// string — no error, even though "dashboard"
// is not in the object
The annotation did its job and then erased everything the
compiler had figured out on its own. routes is now exactly
Record<string, string>. The literal keys are gone. The literal
values are gone. You traded inference for a check.
satisfies is the keyword that lets you keep both. It checks
the value against the type without throwing away the narrow type
the compiler inferred. It landed in TypeScript 4.9 in late 2022,
and config objects are where it earns its place.
What the annotation actually does
A type annotation is a contract in one direction: the value must
be assignable to the declared type, and from that point on the
binding is the declared type. The compiler forgets the
specifics of the literal you wrote.
const colors: Record<string, string> = {
primary: "#1a1a1a",
accent: "#d97b2b",
};
type Keys = keyof typeof colors;
// string — not "primary" | "accent"
keyof typeof colors is string, because as far as the type
system is concerned colors is a Record<string, string> with
arbitrary string keys. The two keys you wrote are an
implementation detail the annotation discarded.
That is fine when you want it. A function parameter typed as
Record<string, string> should accept any such record. But for
a config object you read from, the lost detail is the whole
point of having it in source.
What satisfies does instead
satisfies checks the expression against a type and then leaves
the inferred type in place. Same check, no widening.
const colors = {
primary: "#1a1a1a",
accent: "#d97b2b",
} satisfies Record<string, string>;
type Keys = keyof typeof colors;
// "primary" | "accent"
colors.primary;
// "#1a1a1a" — the literal, not string
colors.missing;
// error: Property 'missing' does not exist
The constraint Record<string, string> still runs. Put a
number where a string belongs and you get the same error you
would with an annotation:
const broken = {
primary: "#1a1a1a",
accent: 0xd97b2b,
} satisfies Record<string, string>;
// error: Type 'number' is not assignable to type 'string'.
So you get the guard rail and you keep the narrow type. The key
names are real. The values are literals. Autocomplete works.
Misspelled lookups fail.
The config-object case where it matters
Here is a shape you have written some version of. A map from a
known set of environments to their settings.
type EnvConfig = {
apiUrl: string;
timeoutMs: number;
retries: number;
};
const config = {
dev: { apiUrl: "http://localhost", timeoutMs: 1000, retries: 0 },
prod: { apiUrl: "https://api.app", timeoutMs: 5000, retries: 3 },
} satisfies Record<string, EnvConfig>;
Each entry is checked against EnvConfig. Forget retries in
one of them and the compiler points at that entry. That is the
annotation half of the job, and satisfies does it.
Now the part an annotation throws away. Because the inferred
type survives, you can derive from it:
type Env = keyof typeof config;
// "dev" | "prod"
function load(env: Env) {
return config[env];
}
load("dev"); // ok
load("staging"); // error: not "dev" | "prod"
Env is the union of the actual keys. Had you annotated
config as Record<string, EnvConfig>, keyof typeof config
would be string and load("staging") would compile and blow
up at runtime when it indexed an object that has no staging
key. The narrow type the compiler kept is what makes the lookup
safe.
Where the annotation still wins
satisfies is not a replacement for annotations. They answer
different questions.
An annotation says: this binding has this type, treat it that
way everywhere. That is what you want for a public API surface,
where the declared type is the thing you are promising and the
literal happens to be how you fill it today.
export const defaults: Settings = {
theme: "dark",
fontSize: 14,
};
Here you want defaults to be Settings, not the narrow shape.
A consumer should see the documented type, and you should be
free to reassign or build defaults differently later without
the inferred type rippling out. The annotation is the right
call.
Annotations also catch a class of error satisfies does not:
the missing property at the point of declaration.
const a: Settings = { theme: "dark" };
// error: Property 'fontSize' is missing
const b = { theme: "dark" } satisfies Settings;
// error: Property 'fontSize' is missing
Both flag it here, so that is a wash. The real split is
direction. An annotation forces the value down to the type.
satisfies checks the value against the type and keeps the
value's own type. When you read from the binding, you almost
always want the second. When you hand the binding to someone
else as a typed contract, you usually want the first.
Combining the two
You can use both when you want a checked, narrow value behind a
declared contract. The order reads outside-in: annotate the
exported name, build it with satisfies so the internal lookups
stay sharp.
const internalRoutes = {
home: "/",
users: "/users",
} satisfies Record<string, string>;
// internal code gets "home" | "users" autocomplete
internalRoutes.home;
// exported surface is the wide, documented type
export const routes: Record<string, string> = internalRoutes;
Inside the module you keep the literal keys. The export presents
the contract. Two bindings, two jobs, no lost information in
either.
The one place satisfies bites
satisfies checks excess properties the way an object literal
does, which can surprise you when the constraint is a union.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
const s = {
kind: "circle",
r: 10,
side: 4,
} satisfies Shape;
// error: Object literal may only specify known
// properties, and 'side' does not exist in
// type ...
The annotation form rejects this too, so the behavior is
consistent. The thing to remember is that satisfies does not
loosen any check. It only changes what type the binding ends up
with. If a value fails an annotation, it fails satisfies with
the same message.
The rule of thumb
Reach for satisfies when you read from the value and want the
compiler to remember the specifics: config maps, route tables,
lookup objects, anything where keyof typeof or a literal value
type is useful downstream. Reach for an annotation when the
declared type is the contract you are exposing and the literal
is just today's filling.
The mistake is using an annotation on a config object you read
from, watching autocomplete go dark, and assuming that is the
cost of type safety. It is not. satisfies gives you the check
and keeps the inference. On a config object, that is the whole
game.
If the difference between widening and constraint inference is
the kind of distinction you want laid out in full — generics,
conditional and mapped types, infer, the machinery satisfies
sits on top of — that is what The TypeScript Type System digs
into. The config-object patterns here are the shallow end of it.
The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
- PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)