Type-safe Config Objects with TypeScript's as const and satisfies

Updated on · 4 min read
Type-safe Config Objects with TypeScript's as const and satisfies

Most apps have a config object somewhere, like a theme or a route map. TypeScript gives you two obvious ways to type one, and both have a catch. Leave the object unannotated, and TypeScript checks nothing until you use it. Annotate it, and the type forgets the values you wrote.

Two keywords fix this. as const keeps the exact values, and satisfies checks the object against a type without replacing the inferred type. Combined, they give you a config that's checked where you write it and precise where you use it.

The problem with plain objects

Here's a theme config and the type it should match:

typescript
interface Theme { mode: "light" | "dark"; spacingUnit?: number; breakpoints: Record<string, number>; } const theme = { mode: "dark", spacingUnits: 8, breakpoints: { mobile: 480, tablet: 768 }, };
typescript
interface Theme { mode: "light" | "dark"; spacingUnit?: number; breakpoints: Record<string, number>; } const theme = { mode: "dark", spacingUnits: 8, breakpoints: { mobile: 480, tablet: 768 }, };

TypeScript reports nothing here. It doesn't know theme is meant to be a Theme, so it infers a type from the values. That type is wider than what you wrote: mode is string instead of "dark". Type inference widens object properties because you could reassign them later.

The error shows up when you pass the object to code that expects a Theme:

typescript
function applyTheme(theme: Theme) { // ... } applyTheme(theme); // Error: Type 'string' is not assignable to type '"light" | "dark"'.
typescript
function applyTheme(theme: Theme) { // ... } applyTheme(theme); // Error: Type 'string' is not assignable to type '"light" | "dark"'.

The error points at the call, far from the config. The spacingUnits typo never gets reported at all. TypeScript flags unknown keys only on an object literal written where a type is expected. Once you store the object in a variable, extra keys pass.

Annotations catch typos but forget values

The usual fix is a type annotation:

typescript
const theme: Theme = { mode: "dark", spacingUnits: 8, // Error: Object literal may only specify known properties, // but 'spacingUnits' does not exist in type 'Theme'. // Did you mean to write 'spacingUnit'? breakpoints: { mobile: 480, tablet: 768 }, };
typescript
const theme: Theme = { mode: "dark", spacingUnits: 8, // Error: Object literal may only specify known properties, // but 'spacingUnits' does not exist in type 'Theme'. // Did you mean to write 'spacingUnit'? breakpoints: { mobile: 480, tablet: 768 }, };

Now TypeScript checks the object where you define it. Fix the typo, though, and look at what the type knows:

typescript
const theme: Theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, }; theme.spacingUnit; // number | undefined theme.breakpoints.tabelt; // No error
typescript
const theme: Theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, }; theme.spacingUnit; // number | undefined theme.breakpoints.tabelt; // No error

The type of theme is now Theme and nothing more. TypeScript forgets that you set spacingUnit, so every read has to handle undefined. It also forgets which breakpoints exist, so a misspelled key like tabelt passes.

as const keeps the exact values

as const works the other way. It tells TypeScript to infer the narrowest type for each value and to mark every property readonly:

typescript
const theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } as const; theme.mode; // "dark" theme.breakpoints.tablet; // 768 theme.breakpoints.tabelt; // Error: Property 'tabelt' does not exist theme.mode = "light"; // Error: Cannot assign to 'mode' because it is a read-only property.
typescript
const theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } as const; theme.mode; // "dark" theme.breakpoints.tablet; // 768 theme.breakpoints.tabelt; // Error: Property 'tabelt' does not exist theme.mode = "light"; // Error: Cannot assign to 'mode' because it is a read-only property.

readonly applies to nested objects and arrays too. TypeScript erases as const when it compiles, so the output is the same plain object.

What as const skips is the check against Theme. The spacingUnits typo would pass again, and so would a missing mode.

satisfies checks without widening

satisfies arrived in TypeScript 4.9 to cover that check. It verifies that a value matches a type, then keeps the type TypeScript inferred:

typescript
const theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } satisfies Theme; theme.mode; // "dark" theme.spacingUnit; // number theme.breakpoints.tabelt; // Error: Property 'tabelt' does not exist
typescript
const theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } satisfies Theme; theme.mode; // "dark" theme.spacingUnit; // number theme.breakpoints.tabelt; // Error: Property 'tabelt' does not exist

Mistakes now fail on the line where you made them:

typescript
const theme = { mode: "drak", // Error: Type '"drak"' is not assignable to type '"light" | "dark"'. spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } satisfies Theme;
typescript
const theme = { mode: "drak", // Error: Type '"drak"' is not assignable to type '"light" | "dark"'. spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } satisfies Theme;

Notice that spacingUnit is number, not 8. satisfies keeps a literal only where the target type uses literal types, like "light" | "dark" for mode. A plain number or string property widens as usual. The object also stays mutable.

Combine them with as const satisfies

For a config that never changes, use both:

typescript
export const theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } as const satisfies Theme; theme.spacingUnit; // 8 theme.breakpoints.tabelt; // Error: Property 'tabelt' does not exist theme.mode = "light"; // Error: Cannot assign to 'mode' because it is a read-only property.
typescript
export const theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } as const satisfies Theme; theme.spacingUnit; // 8 theme.breakpoints.tabelt; // Error: Property 'tabelt' does not exist theme.mode = "light"; // Error: Cannot assign to 'mode' because it is a read-only property.

as const turns the object into a readonly literal type. satisfies then checks that type against Theme. The order matters. satisfies Theme as const is an error, because as const only applies to literals, and a satisfies expression isn't one.

Arrays have one catch: the target type decides whether they stay readonly. If Theme declared fonts: string[], theme.fonts would lose its readonly modifier. Declare fonts: readonly string[] to keep it.

Example: a typed route map

The pattern pays off when other types come from the config. Here's a route map:

typescript
interface Route { path: `/${string}`; auth: boolean; } export const routes = { home: { path: "/", auth: false }, dashboard: { path: "/dashboard", auth: true }, settings: { path: "settings", auth: true }, // Error: Type '"settings"' is not assignable to type '`/${string}`'. } as const satisfies Record<string, Route>;
typescript
interface Route { path: `/${string}`; auth: boolean; } export const routes = { home: { path: "/", auth: false }, dashboard: { path: "/dashboard", auth: true }, settings: { path: "settings", auth: true }, // Error: Type '"settings"' is not assignable to type '`/${string}`'. } as const satisfies Record<string, Route>;

satisfies checks each entry against Route. The template literal type on path catches the missing slash in settings.

Once you add the slash, the config doubles as a source of types:

typescript
type RouteName = keyof typeof routes; // "home" | "dashboard" | "settings" function getPath(name: RouteName) { return routes[name].path; } routes.dashboard.path; // "/dashboard" getPath("profile"); // Error: '"profile"' is not assignable to parameter of type RouteName.
typescript
type RouteName = keyof typeof routes; // "home" | "dashboard" | "settings" function getPath(name: RouteName) { return routes[name].path; } routes.dashboard.path; // "/dashboard" getPath("profile"); // Error: '"profile"' is not assignable to parameter of type RouteName.

satisfies keeps the real keys, so keyof typeof routes is a union of route names. With an annotation, it would be string. as const keeps each path as a literal, so routes.dashboard.path is "/dashboard". Add a route to the config, and RouteName picks it up.

Example: matching locale files

Translations are another good fit. Pick one locale as the source of truth, then check the others against its keys:

typescript
const en = { greeting: "Hello", farewell: "Goodbye", }; export const es = { greeting: "Hola", } satisfies Record<keyof typeof en, string>; // Error: Property 'farewell' is missing.
typescript
const en = { greeting: "Hello", farewell: "Goodbye", }; export const es = { greeting: "Hola", } satisfies Record<keyof typeof en, string>; // Error: Property 'farewell' is missing.

A missing key fails, and so does an extra one. Add a string to en, and every other locale reports what it lacks.

When to skip as const

Literal types work against you when a value is meant to change. Feature flags are one case:

typescript
interface FeatureFlags { newNavbar: boolean; betaSignup: boolean; } const flags = { newNavbar: true, betaSignup: false, } as const satisfies FeatureFlags; if (flags.newNavbar === false) { // Error: This comparison appears to be unintentional // because the types 'true' and 'false' have no overlap. }
typescript
interface FeatureFlags { newNavbar: boolean; betaSignup: boolean; } const flags = { newNavbar: true, betaSignup: false, } as const satisfies FeatureFlags; if (flags.newNavbar === false) { // Error: This comparison appears to be unintentional // because the types 'true' and 'false' have no overlap. }

newNavbar has type true, so TypeScript rejects a check against false. satisfies alone does the same, because boolean is the union true | false.

For values like these, a plain annotation is the better tool. It still catches typos in keys, since the object literal sits right where the type is expected. Each flag stays boolean:

typescript
const flags: FeatureFlags = { newNavbar: true, betaSignup: false, };
typescript
const flags: FeatureFlags = { newNavbar: true, betaSignup: false, };

Config helpers with const type parameters

Many tools, Vite among them, ship a defineConfig function so config files get types without extra syntax. TypeScript 5.0 added const type parameters. With one, your own helper infers literal types as if the caller wrote as const:

typescript
function defineTheme<const T extends Theme>(theme: T): T { return theme; } export const theme = defineTheme({ mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, }); theme.breakpoints.tablet; // 768
typescript
function defineTheme<const T extends Theme>(theme: T): T { return theme; } export const theme = defineTheme({ mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, }); theme.breakpoints.tablet; // 768

The helper checks values against Theme, but it accepts unknown keys. A typo like spacingUnits passes, because excess property checks don't run against a generic constraint. Use as const satisfies when typos in keys matter.

Which one to use

Use satisfies when you want TypeScript to check an object without forgetting what's in it. Add as const when the config is fixed and you want exact values that nobody can reassign. Keep a plain annotation for values that are meant to change, like feature flags.

References