Robust Configuration Objects In TypeScript: The Power of As Const and Satisfies

Updated on · 4 min read
Robust Configuration Objects In TypeScript: The Power of As Const and Satisfies

Struggling with TypeScript config objects that silently accept typos or destroy useful autocomplete? If you've defined settings for React, Node, CLIs, or any JavaScript application, you know the pain. This article explores two TypeScript features - as const assertions and the newer satisfies operator - that enable developers to enforce configuration integrity at compile-time without incurring runtime costs, making your code safer and easier to maintain.

This site is built on Next.js + Tailwind portfolio and blog template. Available on Gumroad.

Why plain objects can betray you

When you write a simple object literal in TypeScript, the compiler often tries to be helpful, but sometimes too helpful:

typescript
// Initial object literal const themeConfig = { mode: "dark", }; // Later... interface Theme { mode: "light" | "dark"; spacingUnit?: number; } // Let's assign it (or pass it to a function expecting Theme) const currentTheme: Theme = themeConfig; // This works! // But what if we had a typo initially? const badConfig = { mod: "dark", // Typo! 'mod' instead of 'mode' }; const oopsTheme: Theme = badConfig; // Still no error here!
typescript
// Initial object literal const themeConfig = { mode: "dark", }; // Later... interface Theme { mode: "light" | "dark"; spacingUnit?: number; } // Let's assign it (or pass it to a function expecting Theme) const currentTheme: Theme = themeConfig; // This works! // But what if we had a typo initially? const badConfig = { mod: "dark", // Typo! 'mod' instead of 'mode' }; const oopsTheme: Theme = badConfig; // Still no error here!

When you write const themeConfig = { mode: 'dark' }, TypeScript infers the type of mode as the general string, not the specific literal 'dark'. This is called "widening". Because string isn't assignable to 'light' | 'dark', the assignment const currentTheme: Theme = themeConfig would actually fail if Theme was used immediately. But often, the check happens later, and crucial type inference information is already lost. Furthermore, excess properties like mod aren't caught reliably once the type is widened or assigned indirectly.

First aid: freezing values with as const

A common first step to retain specific types is using as const:

typescript
const theme = { mode: "dark", rtl: false, } as const; // Now, theme.mode is type 'dark', not string // theme.rtl is type false, not boolean // It's also deeply readonly theme.mode = "light"; // Error! Cannot assign to 'mode' because it is a read-only property.
typescript
const theme = { mode: "dark", rtl: false, } as const; // Now, theme.mode is type 'dark', not string // theme.rtl is type false, not boolean // It's also deeply readonly theme.mode = "light"; // Error! Cannot assign to 'mode' because it is a read-only property.

Adding as const does two key things:

  1. Deep readonly: prevents accidental mutations anywhere inside the object.
  2. Literal types: preserves the most specific type for each value ('dark' instead of string, false instead of boolean).

This approach enhances IntelliSense for property access and can prevent certain classes of errors.

The power of template literal types can further enhance this pattern when working with string manipulations and complex type constraints.

However, as const has limitations. While it helps in inferring precise types for existing properties, it doesn't prevent the inclusion of extraneous properties or ensure all required properties are present according to a specific shape.

The missing piece: satisfies for shape validation (TypeScript 4.9+)

While as const freezes the literal values, satisfies validates the overall shape without throwing away those specific types. It's the perfect complement.

Let's define the shape we want our theme to have:

typescript
interface ThemeShape { mode: "light" | "dark"; rtl?: boolean; // Optional property }
typescript
interface ThemeShape { mode: "light" | "dark"; rtl?: boolean; // Optional property }

Now, let's use satisfies to check our object against this shape:

typescript
const theme = { mode: "dark", rtl: false, // lightness: 0.8, // ← Uncommenting this line results in a TypeScript error. } satisfies ThemeShape;
typescript
const theme = { mode: "dark", rtl: false, // lightness: 0.8, // ← Uncommenting this line results in a TypeScript error. } satisfies ThemeShape;

The satisfies operator provides the following benefits:

  1. Clear shape errors: extraneous properties (like lightness) or properties with incorrect types are flagged directly on the object literal with helpful error messages referencing ThemeShape.
  2. Preserved inference: crucially, theme.mode is still inferred as the literal type 'dark', not widened to string. Autocomplete remains precise!

These validation techniques can be combined with type guards for comprehensive type safety in your applications.

The winning combo: as const satisfies Shape

For immutable configurations (like theme files, feature flags, i18n messages), you often want both shape validation and the benefits of literal types with immutability. You can combine both keywords:

typescript
interface ThemeShape { mode: "light" | "dark"; rtl?: boolean; } // The Gold Standard for immutable configs: export const theme = { mode: "dark", rtl: false, // typo: 'drak' // ← Uncomment for immediate 'mode' type error // extraProp: 123 // ← Uncomment for immediate excess property error } as const satisfies ThemeShape; // Lock types/make readonly FIRST, THEN validate shape // theme.mode is 'dark' (literal type) // theme is readonly // theme conforms to ThemeShape
typescript
interface ThemeShape { mode: "light" | "dark"; rtl?: boolean; } // The Gold Standard for immutable configs: export const theme = { mode: "dark", rtl: false, // typo: 'drak' // ← Uncomment for immediate 'mode' type error // extraProp: 123 // ← Uncomment for immediate excess property error } as const satisfies ThemeShape; // Lock types/make readonly FIRST, THEN validate shape // theme.mode is 'dark' (literal type) // theme is readonly // theme conforms to ThemeShape

The recommended approach is as follows:

  1. Declare an interface or type alias describing the desired object structure.
  2. Write your object literal with the intended values.
  3. Append as const to lock in the literal types and make it deeply readonly.
  4. Append satisfies YourShapeInterface to validate that this specific, readonly structure and its types conform to your intended shape.

(Order matters: as const satisfies Shape works. satisfies Shape as const does not.)

Working with more complex scenarios might require conditional types or intersection types to build sophisticated type definitions.

This combination provides compile-time armor with zero runtime overhead.

Real-world example: feature flags

Imagine managing feature flags:

typescript
// featureFlags.ts interface FeatureFlags { newNavbar: boolean; betaSignup: boolean; darkModeV2?: boolean; // Optional flag } export const flags = { newNavbar: true, betaSignup: false, // darkModV2: true // ← Typo caught instantly by 'satisfies' after 'as const'! } as const satisfies FeatureFlags; // In your React component: // flags.newNavbar has type 'true', flags.betaSignup has type 'false' if (flags.newNavbar) { // ... render new navbar } // If you rename 'newNavbar' to 'navBarV2' in featureFlags.ts, // every usage of 'flags.newNavbar' across your codebase will // immediately show a compile-time error. Ship with confidence!
typescript
// featureFlags.ts interface FeatureFlags { newNavbar: boolean; betaSignup: boolean; darkModeV2?: boolean; // Optional flag } export const flags = { newNavbar: true, betaSignup: false, // darkModV2: true // ← Typo caught instantly by 'satisfies' after 'as const'! } as const satisfies FeatureFlags; // In your React component: // flags.newNavbar has type 'true', flags.betaSignup has type 'false' if (flags.newNavbar) { // ... render new navbar } // If you rename 'newNavbar' to 'navBarV2' in featureFlags.ts, // every usage of 'flags.newNavbar' across your codebase will // immediately show a compile-time error. Ship with confidence!

When working with React components, proper type safety extends to contexts and refs to ensure your entire application remains type-safe.

Broader applications and quick tips

This satisfies (and as const satisfies ...) pattern is powerful for more than just simple configs:

  • i18n translation files: ensure every language file has the required keys
  • tooling configs (ESLint, Tailwind): catch misspelled rule names or config keys
  • API response mocking / snapshots: validate mock data against API interfaces at compile time
  • Form validation: when building forms with React, type-safe validation schemas enhance reliability

For JavaScript developers transitioning to TypeScript, leveraging higher-order functions and tagged templates can become even more powerful when combined with TypeScript's type system.

Closing thoughts

With satisfies and as const, you move from potentially error-prone, loosely-typed configurations to compiler-enforced truth, catching typos and structural issues before they cause runtime bugs. It's a small change in syntax for a big gain in safety and maintainability.

Adopting these patterns for existing and new configuration objects is recommended, as it can often reveal subtle, pre-existing type-related issues. Complement these techniques with defensive programming practices for robust error handling.

As you advance your TypeScript skills, explore mapped types, generics, and the powerful infer keyword to round out your TypeScript expertise.

References and resources