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.
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.
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.
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.
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,
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.
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.
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.
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.
All five books ship in ebook, paperback, and hardcover.