Keeping literals narrow with as const and satisfies
A TypeScript lesson on the as const and satisfies operators, the pair that keeps your config objects narrowly typed while checking them against a contract.
You’ve reached for literal unions every time a field had a finite set of values. The compiler caught typos, autocomplete read your domain, and every status, role, and HTTP method narrowed to exactly what you wrote. That pattern breaks in one place, and it happens to be a shape you write often: a module-level constant whose keys and values both carry meaning.
const ROUTES = { home: '/', about: '/about', pricing: '/pricing',};// ^? { home: string; about: string; pricing: string }Read what TypeScript inferred. You wrote three string literals, '/', '/about', and '/pricing', yet the inferred type is string for every value. Autocomplete on ROUTES.home still works, and keyof typeof ROUTES still gives you 'home' | 'about' | 'pricing' with every key literal-typed. But (typeof ROUTES)[keyof typeof ROUTES], the union of every value the object holds, comes out as string rather than '/' | '/about' | '/pricing'. The literal types disappear the moment they land in an object property. Any later type you derive from those literal paths widens along with them.
This is the bug the lesson on primitives and literal unions named and set aside: object properties widen by default because they’re reassignable. Two operators reverse that decision. as const tells TypeScript to freeze the value’s inferred type at its narrowest. satisfies validates a value against a contract without widening it. Combined as as const satisfies T, they’re what an experienced engineer reaches for on any typed config : a routes map, a permissions table, a feature-flag registry, a Drizzle schema, or a Next.js route segment config.
This lesson installs both operators, explains what each one does on its own, and shows the combined idiom your codebase will use the most. The same shape repeats across Drizzle schemas (Unit 5), Next.js route configs (Unit 4), and permission tables (Unit 9), and every later chapter assumes you read this one.
Why TypeScript widens
Section titled “Why TypeScript widens”Before the new syntax, it helps to understand why the widening happens at all. TypeScript widens literal types in positions where the value could be reassigned. The widening fires in three places:
- Object properties.
{ status: 'draft' }infers{ status: string }becauseobj.statusis mutable: the compiler can’t promise the literal will still be there one line later. - Array element types.
[1, 2, 3]infersnumber[]rather than the tuple[1, 2, 3], because you can write to any index of an array. letbindings.let x = 'draft'infersstring, whileconst x = 'draft'does not. Theconstbinding can’t be reassigned, so the inferred literal survives.
The pattern across all three is the same: where the value can change, the type widens to admit the values it might change to. Widening is the compiler staying honest about what the value might become.
The rule to hold onto:
The widening is reversible.
as constopts every nested literal out of widening;satisfieschecks against a contract without re-applying it.
The next two sections install each operator. The third combines them.
as const: the value-site freeze
Section titled “as const: the value-site freeze”Here is the first new tool, in one sentence:
as consttells TypeScript to freeze every nested literal at its narrowest type and make every propertyreadonly.
The freeze fires on three shapes: primitives, object properties, and arrays. Each one unlocks a different downstream pattern, so we’ll walk them separately.
const ROUTES = { home: '/', about: '/about', pricing: '/pricing',} as const;// ^? { readonly home: '/'; readonly about: '/about'; readonly pricing: '/pricing' }The opening bug, fixed. This is the same ROUTES declaration from the introduction with as const appended. Every value is now the literal path you wrote, not the wider string. The same rule applies to number and boolean literals: { retries: 3, debug: true } as const infers { readonly retries: 3; readonly debug: true }, not { retries: number; debug: boolean }. This is what keyof typeof and typeof X[keyof typeof X] derivations need in order to stay narrow.
const ADMIN_ROLE = { name: 'admin', tier: 'pro',} as const;// ^? { readonly name: 'admin'; readonly tier: 'pro' }The field-modifier shorthand. The lesson on object shapes introduced readonly name: string as a per-field modifier. as const is the bulk form: every property becomes readonly without you typing the keyword on each one. The top level is locked, though nested objects can still mutate, the same caveat the field-level rule named. For shallow configs, which are the common case, as const locks the whole thing.
const VERSIONS = [1, 2, 3] as const;// ^? readonly [1, 2, 3]Tuple of literals, not array of numbers. The lesson on tuples mentioned this form in passing; here it gets its full explanation. Without as const, [1, 2, 3] infers number[]: the length is unknown and every element is a number. With as const, the length is fixed at three, each slot is the literal you wrote, and .push is gone. This is the inline tuple form you’ll meet most often in real code.
One naming point before the next section. The keyword as appears in two operators that mean different things. as Type, the type assertion from the previous lesson, is an escape hatch that silences the compiler at the developer’s risk. as const is a value-site freeze: it tells the compiler “infer this as narrowly as possible,” and the result is sound, because the type the compiler arrives at is a faithful description of the literal value you wrote. Same keyword, two meanings, and this lesson is about the freeze form only.
The three sites where as const earns its weight
Section titled “The three sites where as const earns its weight”The freeze sounds general, but the places it pays off in production code are specific. Three patterns cover most of what you’ll write.
Typed config maps. A routes table, a feature-flag registry, a permission lookup. The values are literal, such as paths, role names, or permission strings, and downstream code derives types from them.
const ROUTES = { home: '/', about: '/about',} as const;
type Path = (typeof ROUTES)[keyof typeof ROUTES];// ^? '/' | '/about'Without as const, Path is string. The derivation reads “every value of ROUTES,” and every value widened to string the moment it landed in the object. The freeze keeps the literals literal, so the derivation reads what you wrote.
Inline tuple literals. Any positional record built at the call site that the caller will destructure and rename: custom hooks, Object.entries rows, coordinate pairs.
const useToggle = () => { const [on, setOn] = useState(false); const toggle = () => setOn((value) => !value); return [on, toggle] as const;};The hook returns readonly [boolean, () => void]. Without as const, the inferred return is (boolean | (() => void))[], a union-typed array, which is useless for the [on, toggle] destructure at the call site. The freeze is what makes each position carry meaning.
Discriminant values. The lesson on composing types introduced the discriminated-union shape, a literal-typed status field on every variant. Build a variant inline without as const and the discriminant widens away.
const result = { status: 'loading' } as const;// ^? { readonly status: 'loading' }Without as const, the inferred type is { status: string }. The variant still compiles into a FetchResult<T> union, but the next switch (r.status) reads against a string and can’t narrow to the loading branch. The discriminated union breaks, and nothing flags it: the code compiles, ships, and surfaces a week later when someone wonders why the loading state never renders. Adding as const prevents that.
These are the three places the freeze pays for itself. Anywhere else, inference handles the case without help.
satisfies: contract check that preserves the narrow
Section titled “satisfies: contract check that preserves the narrow”Here is the second new tool, starting with the bug it fixes.
You’ve added as const, and the literals survive. Now you want a contract: a typo in any value of ROUTES, say home: '#', should error at the literal site, before the file ships. The instinct from every previous lesson is to annotate.
const ROUTES: Record<string, string> = { home: '/', about: '/about',} as const;// ^? { readonly home: string; readonly about: string }The annotation overrides the inference. A type annotation tells the compiler “this value has type T,” and that’s the type it commits to, regardless of the value’s inferred shape. The Record<string, string> does catch a value that isn’t a string, but it pays for the catch by widening every value back to string. The literal paths as const worked to preserve are gone, because the annotation replaces the inferred type rather than checking against it.
const ROUTES = { home: '/', about: '/about',} as const satisfies Record<string, string>;// ^? { readonly home: '/'; readonly about: '/about' }The contract checks, and the literals survive. satisfies validates that the value is assignable to Record<string, string>, the same compile-time check the annotation ran, without re-typing the value as Record<string, string>. The inferred type is still the literal-rich shape as const produced. You get the contract and the narrow type.
Here is satisfies in one sentence:
satisfies Tvalidates that a value is assignable toTwithout applyingTas the value’s type.
Two situations call for it:
You want to keep the narrow type but check it against a contract. This is the canonical case above. The contract is the shape rule: every key maps to a string, every value sits inside an enum, every entry is a valid path. The narrow type is what downstream derivations need, and satisfies keeps both.
You want structural errors at the write site, not the read site. A typo in a key, a missing required field, or a value whose shape doesn’t fit will make satisfies error right at the line where you defined the constant, so the fix is immediate. Without it, the error surfaces wherever the typed config is consumed, sometimes three files away. Sometimes it never surfaces at all, when the consumer happens to read a key that is present. satisfies moves the error back to the source.
The combined idiom: as const satisfies T
Section titled “The combined idiom: as const satisfies T”Each operator is useful on its own. Together they’re the default for any typed config.
Lock the literal types with
as const, then validate against a contract withsatisfies T. The order matters, becausesatisfiesreads the inferred type thatas constproduced.
Here is a full worked example: a permissions table, keyed by the Role literal union, with arrays of Permission literals as values.
type Role = 'admin' | 'member' | 'viewer';type Permission = 'read' | 'write' | 'invite';
const PERMISSIONS = { admin: ['read', 'write', 'invite'], member: ['read', 'write'], viewer: ['read'],} as const satisfies Record<Role, readonly Permission[]>;
type RoleName = keyof typeof PERMISSIONS;// ^? 'admin' | 'member' | 'viewer'
type AdminPerms = (typeof PERMISSIONS)['admin'];// ^? readonly ['read', 'write', 'invite']
type GrantedPermission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS][number];// ^? 'read' | 'write' | 'invite'
// @ts-expect-error — Property 'viewer' is missing in typeconst PERMISSIONS_INCOMPLETE = { admin: ['read', 'write', 'invite'], member: ['read', 'write'],} as const satisfies Record<Role, readonly Permission[]>;The idiom on one line. as const freezes every nested literal, including keys, role names, permission strings, and array positions, so the inferred type is the literal-rich shape. satisfies Record<Role, readonly Permission[]> then runs three checks against that frozen shape: every key must be a member of Role (the completeness check from the dynamic-keys lesson), every value must be a readonly array, and every element of every array must be a Permission. Two operators, three checks, and no loss of literal information.
type Role = 'admin' | 'member' | 'viewer';type Permission = 'read' | 'write' | 'invite';
const PERMISSIONS = { admin: ['read', 'write', 'invite'], member: ['read', 'write'], viewer: ['read'],} as const satisfies Record<Role, readonly Permission[]>;
type RoleName = keyof typeof PERMISSIONS;// ^? 'admin' | 'member' | 'viewer'
type AdminPerms = (typeof PERMISSIONS)['admin'];// ^? readonly ['read', 'write', 'invite']
type GrantedPermission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS][number];// ^? 'read' | 'write' | 'invite'
// @ts-expect-error — Property 'viewer' is missing in typeconst PERMISSIONS_INCOMPLETE = { admin: ['read', 'write', 'invite'], member: ['read', 'write'],} as const satisfies Record<Role, readonly Permission[]>;keyof typeof reads the literal keys. Object keys are literal-typed by default, so even without as const this derivation would still read 'admin' | 'member' | 'viewer'. It’s worth seeing once, because it makes the next two derivations stand out as the ones the freeze unlocks. keyof typeof X is the operator pair that lifts a runtime value’s keys into a type, and the next chapter installs it as a first-class tool.
type Role = 'admin' | 'member' | 'viewer';type Permission = 'read' | 'write' | 'invite';
const PERMISSIONS = { admin: ['read', 'write', 'invite'], member: ['read', 'write'], viewer: ['read'],} as const satisfies Record<Role, readonly Permission[]>;
type RoleName = keyof typeof PERMISSIONS;// ^? 'admin' | 'member' | 'viewer'
type AdminPerms = (typeof PERMISSIONS)['admin'];// ^? readonly ['read', 'write', 'invite']
type GrantedPermission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS][number];// ^? 'read' | 'write' | 'invite'
// @ts-expect-error — Property 'viewer' is missing in typeconst PERMISSIONS_INCOMPLETE = { admin: ['read', 'write', 'invite'], member: ['read', 'write'],} as const satisfies Record<Role, readonly Permission[]>;The value at a key is the literal tuple. Reading the admin slot returns readonly ['read', 'write', 'invite'], the exact set of permissions assigned to admin, in the exact order written, not the wider readonly Permission[] from the contract. Think of the contract as a ceiling: the value is allowed to be anywhere underneath it, and the freeze tells you exactly where it landed. as const satisfies keeps both the ceiling and the precise value below it.
type Role = 'admin' | 'member' | 'viewer';type Permission = 'read' | 'write' | 'invite';
const PERMISSIONS = { admin: ['read', 'write', 'invite'], member: ['read', 'write'], viewer: ['read'],} as const satisfies Record<Role, readonly Permission[]>;
type RoleName = keyof typeof PERMISSIONS;// ^? 'admin' | 'member' | 'viewer'
type AdminPerms = (typeof PERMISSIONS)['admin'];// ^? readonly ['read', 'write', 'invite']
type GrantedPermission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS][number];// ^? 'read' | 'write' | 'invite'
// @ts-expect-error — Property 'viewer' is missing in typeconst PERMISSIONS_INCOMPLETE = { admin: ['read', 'write', 'invite'], member: ['read', 'write'],} as const satisfies Record<Role, readonly Permission[]>;Every permission ever assigned, derived from the config. This chains three steps: take every value ([keyof typeof PERMISSIONS]), then every array element ([number]), and union them together. The result is 'read' | 'write' | 'invite', every permission that appears anywhere in the table. Drop one from the config and the union shrinks; add one and it grows. The type follows the data without you maintaining a second list.
type Role = 'admin' | 'member' | 'viewer';type Permission = 'read' | 'write' | 'invite';
const PERMISSIONS = { admin: ['read', 'write', 'invite'], member: ['read', 'write'], viewer: ['read'],} as const satisfies Record<Role, readonly Permission[]>;
type RoleName = keyof typeof PERMISSIONS;// ^? 'admin' | 'member' | 'viewer'
type AdminPerms = (typeof PERMISSIONS)['admin'];// ^? readonly ['read', 'write', 'invite']
type GrantedPermission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS][number];// ^? 'read' | 'write' | 'invite'
// @ts-expect-error — Property 'viewer' is missing in typeconst PERMISSIONS_INCOMPLETE = { admin: ['read', 'write', 'invite'], member: ['read', 'write'],} as const satisfies Record<Role, readonly Permission[]>;The completeness check fires at the literal site. Drop viewer from the second declaration and satisfies Record<Role, ...> errors immediately, the same completeness check from the dynamic-keys lesson, now wired into the typed-config idiom. Add a new role to the Role union and every PERMISSIONS table in the codebase fails to compile until you fill in the entry. Treat that error as a feature rather than a chore: it’s the type system reminding you the data hasn’t caught up to the domain.
That’s five payoffs from one declaration. Every later chapter leans on a version of this shape, from Drizzle schemas to Next.js route segment configs, feature-flag registries, and RBAC permission tables. When you see as const satisfies T in a codebase, you’re reading the default form for typed configs.
The three forms, one contrast
Section titled “The three forms, one contrast”You now have three operators that overlap in what they do. This table lays the choice out side by side:
| Form | Applies T to the value? | Catches contract violations? | Preserves literal types? |
| --- | --- | --- | --- |
| Annotation : T | Yes | Yes | No (widens) |
| as const | — (no contract) | No (no contract) | Yes |
| satisfies T | No | Yes | Yes |
Three rows, three jobs. The choice follows from the row that matches what you need:
- Annotation when the type itself is the contract: exported function parameters, public type aliases, anything where the consumer reads the signature first. The next lesson on annotation at boundaries covers this case.
as constalone when there’s no contract to validate against: an inline tuple, a hook return, a discriminant value built at the call site. The freeze does its work and nothing else is needed.satisfies T, usually withas const, for typed-config patterns where both the literal types and the contract matter. This is the idiom the lesson installs.
Once the three jobs are distinct, the choice is mechanical.
Practice: type a feature-flag map
Section titled “Practice: type a feature-flag map”You’ve seen the annotation-widens bug, the as const freeze, the satisfies contract check, and the combined idiom on a permissions table. The exercise below puts the idiom on a second shape so the pattern generalizes.
Here is a typed feature-flag map. Keys are flag names from a Flag literal union, and values are deployment stages from a Stage literal union. The contract is Record<Flag, Stage>: every flag must be present, and every value must be a stage. The narrow type, the specific stage each flag is in right now, must survive so downstream code can branch on the exact value.
Define `FEATURE_FLAGS` so the keys exhaust the `Flag` union and each value's literal stage survives. Don't annotate the constant. Reach for `as const satisfies Record<Flag, Stage>`. The two ^? queries must resolve to the indicated types, and the @ts-expect-error directive must fire.
-
Type query at line 14 must resolve to a type containing
"beta-checkout" | "new-dashboard" | "invite-flow" -
Type query at line 17 must resolve to a type containing
"beta"
Pick the right combination
Section titled “Pick the right combination”One closing decision will help fix the form-versus-need matching in your mind. Four scenarios follow, and exactly one of them calls for the combined idiom this lesson installed.
Which of these scenarios calls for as const satisfies T?
(input: { email: string }) => void.reduce over a list of numbers.RouteName and whose values must remain literal paths so type Path = (typeof ROUTES)[keyof typeof ROUTES] narrows.[10, 20, 30]) that needs to be a fixed-length tuple.as const, completeness from satisfies Record<RouteName, string>). The function signature is a public-API boundary that takes an annotation. The reduce result is a local intermediate value — inference handles it. The coordinate tuple needs as const on its own; there’s no contract to validate against.External resources
Section titled “External resources”The official reference for `as const` — the three behaviors (literals stay literal, properties become `readonly`, arrays become `readonly` tuples) named in the release notes that introduced the operator.
The first-party introduction of `satisfies` from the TypeScript 4.9 release notes — walks the annotation-widens bug and the `satisfies` fix on a small typed-config example.
Matt Pocock walks five concrete `satisfies` patterns — route objects, tuples, request bodies — including the `as const satisfies` combination this lesson installs, showing the shape repeats across domains.