Typed configs with as const and satisfies
The TypeScript pair that keeps config objects narrowly typed while checking them against a contract.
Literal unions kept your values narrow: every status, role, and HTTP method typed as exactly what you wrote, with typos caught and autocomplete that knew your domain. That breaks for a shape you write often, a module-level constant whose values carry meaning as much as its keys.
const ROUTES = { home: '/', about: '/about', pricing: '/pricing',};// ^? { home: string; about: string; pricing: string }You wrote three string literals, yet every value inferred as string. The keys stay narrow, keyof typeof ROUTES gives 'home' | 'about' | 'pricing', but the union of the values comes out as string, not '/' | '/about' | '/pricing'. The literals vanish the moment they land in object properties, and any type you derive from them widens too.
This is the widening the literal-unions lesson named and set aside: object properties widen because they’re reassignable. Two operators reverse it. as const freezes the inferred type at its narrowest; satisfies checks a value against a contract without widening it. Together, as const satisfies T pins down any typed config .
Why TypeScript widens literals
Section titled “Why TypeScript widens literals”TypeScript widens a literal wherever the value could later be reassigned:
- Object properties.
{ status: 'draft' }infers{ status: string }, becauseobj.statusis mutable. - Array elements.
[1, 2, 3]infersnumber[], not the tuple[1, 2, 3], because any index is writable. letbindings.let x = 'draft'infersstring, whileconst x = 'draft'stays'draft'.
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.
as const: keep inferred literals narrow
Section titled “as const: keep inferred literals narrow”
as consttells TypeScript to freeze every nested literal at its narrowest type and make every propertyreadonly.
It applies to three shapes: primitives, object properties, and arrays.
const ROUTES = { home: '/', about: '/about', pricing: '/pricing',} as const;// ^? { readonly home: '/'; readonly about: '/about'; readonly pricing: '/pricing' }The opening bug, fixed. Each value is now the literal path you wrote, not the wider string. Numbers and booleans behave the same: { retries: 3, debug: true } as const infers { readonly retries: 3; readonly debug: true }. This is what keyof typeof derivations need to stay narrow.
const ADMIN_ROLE = { name: 'admin', tier: 'pro',} as const;// ^? { readonly name: 'admin'; readonly tier: 'pro' }readonly on every field at once. It locks the top level, though nested objects can still mutate. For shallow configs, the common case, that locks the whole thing.
const VERSIONS = [1, 2, 3] as const;// ^? readonly [1, 2, 3]Tuple of literals, not array of numbers. Without as const, [1, 2, 3] infers number[]: unknown length, every element a number. With it, the length is fixed at three, each slot is the literal you wrote, and .push is gone.
The keyword as carries two operators. as Type, the assertion from the previous lesson, silences the compiler at your own risk. as const is a value-site freeze: it tells the compiler to infer as narrowly as possible, and the result is sound, because the inferred type faithfully describes the value you wrote.
Three places to reach for as const
Section titled “Three places to reach for as const”Typed config maps. A constant of literal paths, role names, or permission strings, with downstream code deriving types from it.
const ROUTES = { home: '/', about: '/about',} as const;
type Path = (typeof ROUTES)[keyof typeof ROUTES];// ^? '/' | '/about'Without as const, every value widens to string and so does Path. The freeze keeps each literal, so the derived union is the exact set you wrote.
Inline tuple literals. A positional value the caller destructures and renames, the shape of any custom hook return.
const useToggle = () => { const [on, setOn] = useState(false); const toggle = () => setOn((value) => !value); return [on, toggle] as const;};This returns readonly [boolean, () => void]. Without as const, it infers (boolean | (() => void))[], where each position could be either type, useless for the [on, toggle] destructure.
Discriminant values. Discriminated unions depend on a literal-typed status field. Build a variant inline without as const and that discriminant widens away.
const result = { status: 'loading' } as const;// ^? { readonly status: 'loading' }Without as const, the type is { status: string }. The variant still fits a FetchResult<T> union, but a later switch (r.status) reads a string and can’t narrow to the loading branch, so the union silently breaks.
satisfies: check a contract without widening
Section titled “satisfies: check a contract without widening”as const kept the literals. Now you want a contract: a typo like home: '#' should error at the literal site, before the file ships. The instinct is to annotate.
const ROUTES: Record<string, string> = { home: '/', about: '/about',} as const;// ^? { readonly home: string; readonly about: string }The annotation overrides the inference. It commits the value to type T, whatever its inferred shape. Record<string, string> catches a non-string value, but widens every value back to string: the literal paths are gone, because an annotation replaces the inferred type instead of 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 runs the same assignability check without re-typing the value as Record<string, string>, so the inferred type stays the literal-rich shape as const produced.
Here is satisfies in one sentence:
satisfies Tvalidates that a value is assignable toTwithout applyingTas the value’s type.
Two situations call for it:
Keep the narrow type while checking it against a contract. The contract is the shape rule: every key maps to a string, every value sits inside an enum. satisfies enforces it while keeping the narrow type downstream derivations need.
Move structural errors to the write site. A typo’d key, a missing field, or a wrong-shaped value errors where you defined the constant, not wherever it’s later consumed, sometimes three files away, sometimes never.
as const satisfies T: the default for typed configs
Section titled “as const satisfies T: the default for typed configs”Lock the literal types with
as const, then validate against a contract withsatisfies T. Order matters:satisfieschecks the typeas constinferred.
A permissions table, keyed by the Role 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, keys, role names, permission strings, and array positions alike. satisfies then checks that frozen shape: every key belongs to Role, every value is a readonly array, every element is a Permission. No literal information is lost.
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 this works even without as const. The pair lifts a value’s keys into a type.
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. The admin slot reads readonly ['read', 'write', 'invite'], the exact permissions in order, not the wider readonly Permission[]. The contract is a ceiling; the freeze tells you where the value actually landed.
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 assigned anywhere, derived from the config. Take every value ([keyof typeof PERMISSIONS]), then every element ([number]), and union them: 'read' | 'write' | 'invite'. Edit the table and the union tracks it, with no second list to maintain.
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 and satisfies errors at once. Add a role to Role and every PERMISSIONS table fails to compile until you fill its entry, the type system flagging that the data hasn’t caught up to the domain.
Annotation vs as const vs satisfies
Section titled “Annotation vs as const vs satisfies”The three forms overlap:
| 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 |
Each row is a different job:
- Annotation when the type itself is the contract: exported function parameters, public type aliases, anything whose signature the consumer reads first.
as constalone when there’s no contract: an inline tuple, a hook return, a discriminant built at the call site.satisfies T, usually withas const, when both the literal types and the contract matter.
Practice: type a feature-flag map
Section titled “Practice: type a feature-flag map”Now apply the idiom to a feature-flag map: keys from a Flag union, a Stage value per flag. The contract Record<Flag, Stage> demands every flag present and every value a stage; each flag’s specific stage must survive so downstream code can branch on it.
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”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.