TypeScript 4.9 shipped a quiet feature that I now use in almost every project: the satisfies operator. If you haven't added it to your workflow yet, this will change how you think about type-safe config objects.
The problem it solves
You have a config object and you want two things simultaneously:
- Type checking that it matches a certain shape
- TypeScript to infer the exact literal types (not widen them)
These two goals conflict with a plain type annotation.
type Routes = Record<string, { path: string; exact?: boolean }>;
// Option A: annotate the type
const routes: Routes = {
home: { path: '/', exact: true },
about: { path: '/about' },
};
// routes.home.path is `string` — TypeScript forgot it's "/"
// routes.home.exact is `boolean | undefined` — TypeScript forgot it's `true`
// Option B: no annotation, use as const
const routes = {
home: { path: '/', exact: true },
about: { path: '/about' },
} as const;
// TypeScript knows exact values, BUT we lose validation against Routes shape
// Typos like `phat` instead of `path` go undetected
Both options lose something important. satisfies gives you both.
satisfies in action
const routes = {
home: { path: '/', exact: true },
about: { path: '/about' },
// contact: { phat: '/contact' } // TypeScript catches this typo!
} satisfies Routes;
// Now routes.home.path is "/" (literal), not string
// And routes.home.exact is true (literal), not boolean | undefined
const homePath = routes.home.path; // type: "/"
The object is validated against Routes at compile time, but TypeScript retains the narrow literal types for downstream use.
Real-world patterns where this shines
1. Theme configuration
type Theme = {
colors: Record<string, string>;
spacing: Record<string, number>;
};
const theme = {
colors: {
primary: '#6366f1',
secondary: '#8b5cf6',
error: '#ef4444',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
} satisfies Theme;
// TypeScript knows theme.colors.primary is "#6366f1", not just string
type PrimaryColor = typeof theme.colors.primary; // "#6366f1"
2. API route registry
type ApiRoute = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
handler: string;
auth?: boolean;
};
const apiRoutes = {
getUser: { method: 'GET', handler: 'users/get', auth: true },
createUser: { method: 'POST', handler: 'users/create' },
deleteUser: { method: 'DELETE', handler: 'users/delete', auth: true },
// badRoute: { method: 'FETCH', handler: 'foo' } // Error: FETCH not in union
} satisfies Record<string, ApiRoute>;
// Each route method is narrowed to its literal: 'GET', 'POST', etc.
type GetUserMethod = typeof apiRoutes.getUser.method; // "GET"
3. Feature flag registry
type FeatureFlag = {
enabled: boolean;
rolloutPercent?: number;
description: string;
};
const flags = {
newDashboard: {
enabled: true,
rolloutPercent: 25,
description: 'Redesigned analytics dashboard',
},
aiAssistant: {
enabled: false,
description: 'Claude-powered code assistant',
},
} satisfies Record<string, FeatureFlag>;
// flags.newDashboard.enabled is `true` (literal), not `boolean`
Combining satisfies with as const
For deeply nested objects where you want complete literal narrowing:
const config = {
env: 'production',
features: {
ssr: true,
analytics: true,
experiments: false,
},
limits: {
maxConnections: 100,
timeoutMs: 5000,
},
} as const satisfies AppConfig;
// Both fully narrowed AND validated against AppConfig shape
When to use satisfies vs type annotation vs as const
| Goal | Use |
|---|---|
| Just validate shape, widened types OK | : TypeAnnotation |
| Exact literal types, no shape validation | as const |
| Both: validate shape + keep literal types | satisfies |
| Both + deeply immutable | as const satisfies |
The pattern I use in every Next.js project
In lib/config.ts:
type AppConfig = {
site: {
name: string;
url: string;
description: string;
};
auth: {
providers: Array<'github' | 'google' | 'email'>;
sessionMaxAge: number;
};
stripe: {
plans: Record<string, { priceId: string; limits: Record<string, number> }>;
};
};
export const config = {
site: {
name: 'My SaaS',
url: 'https://mysaas.com',
description: 'The best SaaS you ever used',
},
auth: {
providers: ['github', 'google'],
sessionMaxAge: 60 * 60 * 24 * 30,
},
stripe: {
plans: {
pro: {
priceId: 'price_xxx',
limits: { apiCalls: 10000, seats: 5 },
},
},
},
} satisfies AppConfig;
// Consumers get full literal type inference:
type SiteName = typeof config.site.name; // "My SaaS"
This means autocomplete works perfectly, refactoring is safe, and you catch mismatches against your config schema at compile time.
If you're building a TypeScript-first SaaS and want a starter that uses patterns like this throughout — typed config, type-safe env vars, Stripe integration — check out the AI SaaS Starter Kit at whoffagents.com. Ships with Next.js 14, Stripe billing, and auth all pre-wired with proper TypeScript patterns.
Top comments (0)