Skip to content
Chapter 4Lesson 7

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.

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 } because obj.status is mutable: the compiler can’t promise the literal will still be there one line later.
  • Array element types. [1, 2, 3] infers number[] rather than the tuple [1, 2, 3], because you can write to any index of an array.
  • let bindings. let x = 'draft' infers string, while const x = 'draft' does not. The const binding 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 const opts every nested literal out of widening; satisfies checks against a contract without re-applying it.

The next two sections install each operator. The third combines them.

Here is the first new tool, in one sentence:

as const tells TypeScript to freeze every nested literal at its narrowest type and make every property readonly.

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.

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.

Here is satisfies in one sentence:

satisfies T validates that a value is assignable to T without applying T as 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.

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 with satisfies T. The order matters, because satisfies reads the inferred type that as const produced.

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 type
const 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 type
const 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 type
const 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 type
const 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 type
const 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.

1 / 1

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.

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 const alone 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 with as 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.

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"
Booting type-checker…

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?

The signature of an exported function — (input: { email: string }) => void.
A local variable holding the result of a reduce over a list of numbers.
A module-level routes map whose keys must cover every RouteName and whose values must remain literal paths so type Path = (typeof ROUTES)[keyof typeof ROUTES] narrows.
An array of three coordinate numbers ([10, 20, 30]) that needs to be a fixed-length tuple.