Short version for the impatient: satisfies lets you type-check an object against a type without throwing away what TypeScript already inferred about it. If you’ve been reaching for as on config objects, you probably want satisfies instead. If you want to know why, read on.
I shipped a bug last year that a single as hid from me for the better part of a month. It was a theme config, a plain object mapping color names to values. Some values were hex strings, one was an RGB tuple because I was mid-refactor and hadn’t finished. I slapped as Record<string, string> on the end to make the red squiggles go away, told myself I’d clean it up later, and moved on. The cast lied. TypeScript stopped complaining, the tuple stayed a tuple at runtime, and a .toUpperCase() somewhere downstream blew up in a component that only rendered on one specific settings page. I found it three weeks later. That’s the day I actually learned what satisfies is for.
The problem with as
as is not a check. It’s you telling the compiler “trust me, I know the type here,” and the compiler shrugging and believing you. That’s fine when you genuinely know something it can’t, like narrowing a DOM node you just created. It’s a foot-gun on object literals, because the one place you’d want validation is exactly the place as switches it off.
Here’s the shape of what bit me:
type Color = string;
const theme = {
primary: "#2563eb",
danger: "#dc2626",
border: [229, 231, 235], // oops, a tuple
} as Record<string, Color>;
theme.border.toUpperCase(); // compiles fine. explodes at runtime.
The as promised the compiler every value is a Color. It isn’t. And because I asserted it, TypeScript never checked. No error at the definition, no error at the call site. The bug just waited.
What satisfies actually does
satisfies landed in TypeScript 4.9, and the whole idea is small: check that a value conforms to a type, but keep the narrow type TypeScript inferred instead of widening everything to the annotation. You can read the original writeup in the TypeScript 4.9 release notes, which is where the canonical example comes from. If you want the archaeology, it grew out of a long-running request tracked in TypeScript issue #7481 before it finally shipped.
Swap as for satisfies in that theme and the compiler catches the tuple immediately:
const theme = {
primary: "#2563eb",
danger: "#dc2626",
border: [229, 231, 235],
} satisfies Record<string, string>;
// Error: Type 'number[]' is not assignable to type 'string'.
That’s the entire pitch. satisfies runs the same assignability check as refuses to run, and it does it without changing the type of theme. Fix the value and the error clears. No cast, no lie, no three-week wait.
Why not just annotate the type?
Fair question, and it’s the one I asked first. Why not write const theme: Record<string, string> = {...} and be done? Because a type annotation checks the value but also flattens it. Once you annotate with Record<string, string>, every property is now typed as string and you lose the specific keys.
The difference shows up the moment you read the object back:
// With annotation: keys and value types are widened
const theme: Record<string, string> = {
primary: "#2563eb",
danger: "#dc2626",
};
theme.primaryy; // no error. it's Record<string, string>, any key goes.
// With satisfies: you get the check AND the exact shape
const theme = {
primary: "#2563eb",
danger: "#dc2626",
} satisfies Record<string, string>;
theme.primaryy; // Error: Property 'primaryy' does not exist.
Annotation gives you validation and throws away the inference. as gives you the inference and throws away the validation. satisfies is the one that keeps both. That’s genuinely the whole reason it exists.
Where I actually reach for it
The theme object was the toy case. Where satisfies earns its place in my code is route maps and config, anywhere I want a literal object that’s both checked and precisely typed so autocomplete stays useful.
Route tables are my favorite example. I want every value to be a valid path, and I still want to reference routes.profile and get the exact string back, not string:
const routes = {
home: "/",
profile: "/user/:id",
settings: "/settings",
} satisfies Record<string, `/${string}`>;
// typo in a path is caught:
// checkout: "checkout" -> Error, doesn't start with "/"
// and routes.profile is still the literal "/user/:id", not string
The template literal type does the guarding, satisfies does the checking, and I keep the narrow literal types for everything that consumes routes later. Same trick works for a config object where some keys are required and you want to catch a missing one at definition time instead of at 2am.
This is the same instinct behind reaching for stricter types elsewhere in a codebase. I wrote about a related pattern in my post on branded types and the ID mix-ups I stopped shipping, and the through-line is the same: let the compiler hold the invariant so you don’t have to remember it. If you’ve read the handbook section on satisfies, you’ve seen the palette example that makes this click for most people.
satisfies vs as vs annotation, in one place
Here’s how I decide, since the three blur together until you’ve been burned once. Use a plain annotation when you want the variable to be the general type on purpose, like a function parameter default or something you’ll reassign. Use as only when you know something the compiler genuinely cannot, and you’re willing to own the risk. Reach for satisfies for literal objects you’re defining once and want both checked and precisely typed.
A quick side by side on the same object:
const config = { retries: 3, timeout: 5000 };
config as Record<string, number>; // no check, widened
// const c: Record<string, number> = ... // check, but widened to number
config satisfies Record<string, number>; // check, and stays { retries: number; timeout: number }
If you can only remember one rule: the second you’re tempted to write as on an object or array literal, stop and try satisfies. It’s almost always what you actually meant.
When I don’t bother
satisfies is not a thing to sprinkle everywhere, and I’ve seen codebases go a little overboard with it after someone discovers it. If the inferred type is already fine and you don’t need to constrain it, adding satisfies SomeType is just noise. A simple const port = 3000 doesn’t need satisfies number. TypeScript already knows.
It also can’t save you from bad runtime data. satisfies is a compile-time check on literals you wrote yourself. The moment your data comes from a network request or a JSON file, you’re back to needing an actual runtime validator like Zod or Valibot, because the compiler has no idea what the server sent. satisfies guards the stuff you type by hand. It does nothing for the stuff that arrives while your program runs. Keep those two jobs separate in your head and you won’t misuse it.
Try this this week
Grep your codebase for as sitting right after a } or a ]. Those are your object and array literals with a cast on them, and a good chunk of them want to be satisfies instead:
grep -rn "} as \|] as " src/
Go through the hits. For each one, swap as for satisfies and see what lights up red. Some will be fine. A few will surface a mismatch that was quietly wrong, the way my theme tuple was. Fix those, keep the swap, and move on. It took me about twenty minutes across a mid-sized project and it turned up two real bugs, which is a better hit rate than most refactors I do on a Friday. I keep this kind of type-safety cleanup on my running list of things worth doing between features, some of which I write up over on my site.
You don’t need to adopt anything new to do this. satisfies has been in the language since 4.9. It’s just sitting there, quietly better than the cast you’ve been reaching for.