A proof of concept for eliminating an entire class of silent runtime bugs using three TypeScript primitives that already exist in your stack.
This repo is the reference implementation. Read the full writeup: config-to-types pipeline
These bugs ship to production constantly:
// flags
useFlag('premiumFeaure') // typo — silently returns false, gate is open
// translations
t('nav.hme') // typo — silently renders raw key or empty string
// env
process.env.VITE_API_URL // removed from .env — silently undefined at runtime
// design system
<Button size="extralarge" /> // not a real size — silently falls back or breaksNone of these are caught by TypeScript because the APIs accept string. The type is correct — the contract is wrong. There is no enforcement between what your config defines and what your code consumes.
The usual fixes are runtime validation, integration tests, or careful code review. All of them catch the bug after it exists.
Three TypeScript/Vite primitives, combined:
1. as const — freeze your config as a literal type
export const flags = {
darkMode: false,
premiumFeature: false,
} as const;2. keyof typeof — derive a union from the config
import type { flags } from '@yarcl/flags';
export type FlagKey = keyof typeof flags; // "darkMode" | "premiumFeature"3. Vite module alias — point @yarcl/flags at the consumer's config
// vite.config.ts
resolve: {
alias: { '@yarcl/flags': '/src/flags.config.ts' }
}The result: useFlag() only accepts "darkMode" | "premiumFeature". A typo is a red squiggle before you save the file. Rename a flag in config and every stale callsite breaks at compile time, not in production.
No codegen. No CLI. No runtime type engine. The config file is the type definition.
The same three primitives apply to four common problems:
// consumer/src/yarcl.config.ts
export const yarcl = {
button: {
sizes: { xs: '28px', sm: '36px', md: '44px', lg: '52px' },
radii: { none: '0px', sm: '4px', md: '8px', rounded: '9999px' },
},
colors: { primary: '#2d4bb8', danger: '#dc2626' },
} as const;
// library derives:
type ButtonSize = keyof typeof yarcl.button.sizes; // "xs" | "sm" | "md" | "lg"
type ButtonRadius = keyof typeof yarcl.button.radii; // "none" | "sm" | "md" | "rounded"
type ButtonColor = keyof typeof yarcl.colors; // "primary" | "danger"
// consumer gets:
<Button size="xs" color="primary" /> // ✓
<Button size="xxl" /> // ✗ TS errorThe consumer controls exactly how many sizes, radii, and colors exist. Three brand colors? Two button sizes? The type system enforces it — nothing else is valid.
// consumer/src/flags.config.ts
export const flags = {
darkMode: false,
betaEditor: true,
premiumFeature: false,
} as const;
// usage
const enabled = useFlag('betaEditor'); // ✓ boolean
const enabled = useFlag('betaEditr'); // ✗ TS errorRename or remove a flag in config → every stale useFlag() call breaks at compile time.
// consumer/src/i18n.config.ts
export const translations = {
en: { 'nav.home': 'Home', 'button.submit': 'Submit' },
es: { 'nav.home': 'Inicio', 'button.submit': 'Enviar' },
} as const;
// usage
const { t } = useTranslation('en');
t('nav.home') // ✓ "Home"
t('nav.hme') // ✗ TS error — not a key in the configCompare to i18next TypeScript support, which requires module augmentation in a separate .d.ts file that must be kept in sync manually. This approach starts typed — the config is the source of truth, no gap to maintain.
// consumer/src/env.config.ts
export const envSchema = {
VITE_API_URL: '',
VITE_APP_NAME: '',
} as const;
// usage
env.VITE_API_URL // ✓ string
env.VITE_TYPO // ✗ TS error — not declared in schemaOnly variables declared in the schema are accessible. Remove a variable from the schema and all usages break immediately.
consumer/src/flags.config.ts (as const)
↓ Vite alias: @yarcl/flags
library/src/features/flags/flags.types.ts
export type FlagKey = keyof typeof flags
↓
library/src/features/flags/useFlag.ts
export function useFlag(key: FlagKey): boolean
↓
consumer/src/App.tsx
useFlag('betaEditor') ← only valid keys autocomplete
The library never hardcodes the consumer's config. The alias makes the consumer's file the module the library imports from. Types flow through — the library's types are the consumer's types.
The tools always existed. as const has been in TypeScript since 3.4 (2019). Module aliases are in every bundler. keyof typeof is basic TypeScript.
The insight is that these three primitives, combined, eliminate the need for:
- Codegen (Panda CSS, i18next-codegen)
- Runtime type engines (Stitches, vanilla-extract)
- Module augmentation boilerplate (i18next TypeScript guide)
- Manual union types that go stale
Each existing solution solves one problem. This is a general pattern.
Vite-only (currently). The plugin uses Vite's resolve.alias. A webpack equivalent is straightforward but not built yet. Next.js, Rsbuild, and other bundlers would each need their own adapter.
Needs library adoption. For maximum value, hooks like useTranslation and useFlag should be distributed by the actual libraries (i18next, LaunchDarkly, etc.) with config-aware types. Until then, the pattern requires either using this library's implementations or wrapping existing ones.
No runtime validation. Types are erased at runtime. If your config changes between build and deploy, nothing catches it. This is intentional — the pattern is about development-time correctness, not runtime guarantees. Pair it with Zod schemas if you need runtime validation.
pnpm install
cd consumer && pnpm devTry these in consumer/src/App.tsx to see the type errors:
useFlag('nonExistentFlag') // TS error
t('missing.key') // TS error
env.UNDECLARED_VAR // TS error
<Button size="gigantic" /> // TS errorAdd a new key to any config file — it becomes valid immediately, with autocomplete.
library/ — the pattern implementation (package: yarcl)
consumer/ — demo app showing all four pipelines