Type-safe Config Objects with TypeScript's as const and satisfies
Updated on · 4 min read|
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:
typescriptinterface Theme { mode: "light" | "dark"; spacingUnit?: number; breakpoints: Record<string, number>; } const theme = { mode: "dark", spacingUnits: 8, breakpoints: { mobile: 480, tablet: 768 }, };
typescriptinterface 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:
typescriptfunction applyTheme(theme: Theme) { // ... } applyTheme(theme); // Error: Type 'string' is not assignable to type '"light" | "dark"'.
typescriptfunction 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:
typescriptconst 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 }, };
typescriptconst 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:
typescriptconst theme: Theme = { mode: "dark", spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, }; theme.spacingUnit; // number | undefined theme.breakpoints.tabelt; // No error
typescriptconst 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:
typescriptconst 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.
typescriptconst 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:
typescriptconst 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
typescriptconst 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:
typescriptconst theme = { mode: "drak", // Error: Type '"drak"' is not assignable to type '"light" | "dark"'. spacingUnit: 8, breakpoints: { mobile: 480, tablet: 768 }, } satisfies Theme;
typescriptconst 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:
typescriptexport 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.
typescriptexport 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:
typescriptinterface 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>;
typescriptinterface 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:
typescripttype 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.
typescripttype 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:
typescriptconst en = { greeting: "Hello", farewell: "Goodbye", }; export const es = { greeting: "Hola", } satisfies Record<keyof typeof en, string>; // Error: Property 'farewell' is missing.
typescriptconst 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:
typescriptinterface 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. }
typescriptinterface 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:
typescriptconst flags: FeatureFlags = { newNavbar: true, betaSignup: false, };
typescriptconst 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:
typescriptfunction 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
typescriptfunction 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
- Literal inference in the TypeScript handbook
- Const assertions in the TypeScript 3.4 release notes
- The satisfies operator in the TypeScript 4.9 release notes
- Const type parameters in the TypeScript 5.0 release notes


