- Book: TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser
- 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 write a config object. You give it a type. The type-checker
agrees the shape is right. Then you reach into the object for a
specific field, and the inferred type comes back as string
instead of the literal "production" you actually wrote. The
discriminated union you were going to switch on collapses into
a wide string. The autocomplete on the next line gives you
nothing.
This is what happens when you annotate a value with : Type or
cast it with as Type. Both lie about how much the compiler
knew. Annotation widens the value to the type. The cast tells
the compiler to stop checking and trust the programmer. Either
way, the literal information that was sitting in your source
code right there in plain text gets thrown away before any other
code in the file runs.
TypeScript 4.9
shipped a third operator that does what most people wanted in
the first place. It validates the value against a type without
widening. The keyword is satisfies. It is the infrastructure
piece that killed off most of the as casts you find in code
review.
What satisfies Actually Does
The signature reads like an assertion in English. This value
satisfies that contract. The compiler checks the value against
the type. Missing a required field is an error. Extra field,
error. Wrong type for a field, error. So far this looks
identical to a type annotation.
The difference is what the variable's type becomes after the
check passes.
type Env = {
name: string;
region: "us-east-1" | "eu-west-1" | "ap-south-1";
features: Record<string, boolean>;
};
const cfg: Env = {
name: "prod",
region: "us-east-1",
features: { newCheckout: true, oldDashboard: false },
};
cfg.region;
// Type: "us-east-1" | "eu-west-1" | "ap-south-1"
cfg.features.newCheckout;
// Type: boolean
The annotation widens. cfg.region is now the full union, not
the literal you wrote. cfg.features.newCheckout is boolean,
not the true that is sitting on disk. If you switch on
cfg.region you get every branch even though only one is
reachable. If you index cfg.features with a key that does not
exist, the checker happily hands you boolean instead of
flagging the typo.
satisfies does the validation without the widening.
const cfg = {
name: "prod",
region: "us-east-1",
features: { newCheckout: true, oldDashboard: false },
} satisfies Env;
cfg.region;
// Type: "us-east-1"
cfg.features.newCheckout;
// Type: true
cfg.features.checkout;
// Error: Property 'checkout' does not exist
The validation still runs. Typo region: "us-east-2" and the
check fires. Forget name and the check fires. Add an
extra: 1 and the excess-property rule trips the same way an
annotation would. Past that point the variable's type is the
literal shape you wrote, not the contract. Downstream code
gets to work with the real information.
The as form looks similar at first glance.
const cfg = {
name: "prod",
region: "us-east-1",
features: { newCheckout: true, oldDashboard: false },
} as Env;
This widens the same way an annotation does, and worse: a cast
suppresses checks. Write region: "us-east-2" (without
as const) inside an as Env cast and the compiler often lets
it through, because the wider object literal type is treated as
assignable. Reach for as unknown as Env and even an outright
shape mismatch goes silent. as is a hammer the checker stops
arguing with. satisfies is the checker doing its job and then
handing you back what you wrote.
Pattern 1: Config With Preserved Literals
Configuration objects are the cleanest example. You want
validation and you want the discriminated unions to keep working
inside the consumer code.
type Provider =
| { kind: "openai"; model: "gpt-4o" | "gpt-4o-mini"; key: string }
| { kind: "anthropic"; model: "claude-opus" | "claude-haiku"; key: string }
| { kind: "ollama"; host: string };
const providers = {
primary: {
kind: "anthropic",
model: "claude-opus",
key: process.env.ANTHROPIC_KEY ?? "",
},
fallback: { kind: "ollama", host: "http://localhost:11434" },
} satisfies Record<string, Provider>;
function call(p: Provider) {
switch (p.kind) {
case "openai": return p.model;
case "anthropic": return p.model;
case "ollama": return p.host;
default: {
const _exhaustive: never = p;
return _exhaustive;
}
}
}
call(providers.primary); // ok
providers.primary.kind; // "anthropic"
providers.fallback.host; // string — known to be the ollama variant
providers.primary.kind is "anthropic", not the wider
"openai" | "anthropic" | "ollama". The switch in call
narrows correctly. providers.fallback.host is known to exist
because the literal type still says it does. None of this works
if the object is annotated : Record<string, Provider> directly.
Annotated, every entry collapses to Provider and you lose the
information that primary is the Anthropic variant.
The same pattern handles feature flags, route configs, and the
sort of static manifests where you want the type system to know
"this key has this exact value" right up to the consumer.
Pattern 2: A Typed Route Table With Narrow Handlers
Route tables are where satisfies quietly outclasses every
previous workaround. You want a registry where each path has its
own handler with its own param types, and you want the registry
itself to be typed against a contract.
type Route = {
method: "GET" | "POST" | "PUT" | "DELETE";
handler: (args: { params: any; body: any }) => Promise<Response>;
};
const routes = {
"/users/:id": {
method: "GET",
handler: async ({ params }: {
params: { id: string };
body: undefined;
}) => Response.json({ id: params.id }),
},
"/orders": {
method: "POST",
handler: async ({ body }: {
params: object;
body: { items: string[]; total: number };
}) => Response.json({ ok: body.items.length > 0 }),
},
} satisfies Record<string, Route>;
routes["/users/:id"].method;
// "GET", not "GET" | "POST" | "PUT" | "DELETE"
routes["/orders"].handler;
// keeps the narrow body type { items: string[]; total: number }
The contract uses any for params and body deliberately —
under --strictFunctionTypes, function parameters are
contravariant, so a handler that requires { id: string }
would not be assignable to one that accepts any object.
Widening the contract to any is the standard pattern for
registries like this: the contract waves through any handler
shape, and satisfies then preserves whatever narrow shape each
route actually wrote.
The annotation form : Record<string, Route> would erase those
specific handler signatures. Every route's handler would
collapse to the wide contract type, and the body type for
/orders would dissolve. With satisfies, the registry is
validated against the contract, but the lookup keeps the narrow
type each handler was authored with. A test or a router that
reaches in and pulls a specific route gets the precise types
back. Response.json here is the Fetch-API helper available in
Bun, Deno, and Node 21+; on older Node, swap for your framework's
response builder.
The exhaustiveness check on the union of methods is the same
pattern as a discriminated-union switch — drop a
const _: never = method in the default branch of any
method-dispatch switch and the compiler nags you the moment you
add a fifth method to Route. The handlers do not need to
repeat their param shapes in a separate types file.
Pattern 3: A Typed Tailwind-Style Theme
Theme objects sit at the boundary between design tokens and
TypeScript code. You want them validated as a Theme (so a
typo on a color name is caught at build time) and you want
consumers to autocomplete the actual keys, not a generic
Record<string, string>. The Theme shape below is a minimal
sketch — the real Tailwind types accept more variants per token
— but the pattern is the same.
type Theme = {
colors: Record<string, string>;
spacing: Record<string, string>;
fontSize: Record<string, [string, { lineHeight: string }]>;
};
const theme = {
colors: {
bg: "#f2ede1",
ink: "#1a1a1a",
accent: "#d97b2b",
danger: "#b91c1c",
},
spacing: {
xs: "0.25rem",
sm: "0.5rem",
md: "1rem",
lg: "2rem",
},
fontSize: {
sm: ["0.875rem", { lineHeight: "1.25rem" }],
base: ["1rem", { lineHeight: "1.5rem" }],
xl: ["1.25rem", { lineHeight: "1.75rem" }],
},
} satisfies Theme;
type ColorName = keyof typeof theme.colors;
// "bg" | "ink" | "accent" | "danger"
type FontSize = keyof typeof theme.fontSize;
// "sm" | "base" | "xl"
function color(name: ColorName) {
return theme.colors[name];
}
color("accent"); // ok
color("primary"); // error: not assignable
keyof typeof theme.colors is what makes this pattern click.
The compiler knows the literal keys, so a ColorName type
follows directly. A function that takes a ColorName only
accepts the four real colors. Add a fifth color to the theme
file and the union expands automatically. Remove one and every
caller that referenced it stops compiling.
The annotated version, const theme: Theme = { ... }, gives you
keyof typeof theme.colors === string. Useless. The as Theme
version is the same kind of useless plus the missing safety
net. satisfies is the only form that does both.
When as Still Earns Its Keep
satisfies does not replace as everywhere. The cases where
as is still the right tool:
-
Truly opaque values. A
Bufferyou read off a socket that you know is a JPEG header. Aunknownthat came back fromJSON.parseafter you ran a runtime validator. The compiler genuinely cannot check; you are the source of truth for the shape, andasis the place where you take that responsibility. -
Brand constructors. The one-line
return s as UserIdat the bottom of a validating constructor (the branded-types pattern). The cast there is load-bearing — it is the only way to mint a value of the brand.satisfiescannot do this because the runtime value really is a plain string and only becomes aUserIdby fiat. -
Const assertions through external APIs. Some library
signatures take
as constarrays wheresatisfieswould widen back. Read the library's types before assumingsatisfiesis a drop-in. -
Test doubles. A mock object that intentionally only
implements the parts of an interface a test exercises.
aswith the appropriateunknownstep (x as unknown as Service) is honest about what is happening.
Every other as in the codebase is worth a second look. If the
value really does match the contract, satisfies validates that
match without throwing away the literal information. If the
value does not match the contract, the cast was hiding a bug.
Either way the change is an improvement.
What To Do With An Existing Codebase
The migration is mechanical. A regex like \bas\s+[A-Z]\w+\b
finds casts to your own PascalCase type names while skipping
as const, as unknown, and as any. For each match, try
satisfies in its place. If the file still compiles, keep the
change. If it stops compiling, the cast was actually suppressing
an error and now you see what.
A second sweep: search for : Record<string, ...> annotations
on object literals. Those are the ones bleeding literal types.
Swap : Record<...> for satisfies Record<...> across a real
repo and the autocomplete on consumers turns from generic to
specific. Errors that used to land three files away show up at
the line that caused them. The diff is two characters and a
keyword; the downstream effect on inference is large.
If this was useful
The satisfies operator is one of the small mechanics that
TypeScript Essentials covers as part of the inference story —
how the compiler reads literal types, when it widens, where you
lose information, and which operator stops the loss. Once you
have that picture, a lot of the friction with Record, with
discriminated unions, and with config objects disappears. If
the patterns above made sense and you want the inference
machinery behind them, that is the book.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471

Top comments (0)