TypeScript const Type Parameters
The const modifier on generics preserves literal types instead of widening—making type inference match your intent.
Call a generic function with { status: "active" } and TypeScript infers { status: string }. The literal "active" gets widened to string. For type-safe APIs, discriminated unions, and builder patterns, that widening breaks everything. The const type parameter fixes it.
The Problem: Type Widening in Generics
function createConfig<T>(config: T): T {
return config;
}
// What you pass
const config = createConfig({
env: "production",
port: 3000
});
// What TypeScript infers
// config: { env: string; port: number }
// But you wanted
// config: { env: "production"; port: 3000 }TypeScript widens literals to their base types when inferring generic parameters. This is usually helpful—you don't want 3000 to be typed as 3000 in most cases. But sometimes you need the literal.
The Solution: const Type Parameters
Add const before the type parameter to preserve literals:
function createConfig<const T>(config: T): T {
return config;
}
const config = createConfig({
env: "production",
port: 3000
});
// config: { readonly env: "production"; readonly port: 3000 }
// ↑↑↑ ↑↑↑
// Literal types preserved!The const modifier tells TypeScript: "Infer this as if the argument had as const."
Where This Matters
1. Route Definitions and Type-Safe Routing
function defineRoutes<const T extends Record<string, string>>(routes: T) {
return routes;
}
const routes = defineRoutes({
home: "/",
user: "/user/:id",
settings: "/settings"
});
// routes: { readonly home: "/"; readonly user: "/user/:id"; readonly settings: "/settings" }
type RouteKeys = keyof typeof routes; // "home" | "user" | "settings"
type RoutePaths = typeof routes[RouteKeys]; // "/" | "/user/:id" | "/settings"Without const, RouteKeys would be string and RoutePaths would be string—useless for type-safe navigation.
2. Builder Patterns with Discriminated Unions
function action<const T extends { type: string }>(config: T) {
return config;
}
const increment = action({ type: "INCREMENT", amount: 1 });
const reset = action({ type: "RESET" });
// increment: { readonly type: "INCREMENT"; readonly amount: 1 }
// reset: { readonly type: "RESET" }
type Actions = typeof increment | typeof reset;
// Proper discriminated union!3. Event Emitter Type Safety
function createEmitter<const T extends Record<string, unknown[]>>(events: T) {
return {
on<K extends keyof T>(event: K, handler: (...args: T[K]) => void) { },
emit<K extends keyof T>(event: K, ...args: T[K]) { }
};
}
const emitter = createEmitter({
click: [{ x: 0, y: 0 }],
hover: [{ target: "" }]
});
// Type-safe event handling
emitter.on("click", (pos) => {
// pos: { readonly x: 0; readonly y: 0 }
});The readonly Caveat
const type parameters make inferred types readonly. This can cause issues:
function createArray<const T extends unknown[]>(items: T): T {
return items;
}
const nums = createArray([1, 2, 3]);
// nums: readonly [1, 2, 3]
nums.push(4); // ❌ Error: Property 'push' does not exist on type 'readonly [1, 2, 3]'If you need mutability, either avoid const or explicitly type the return:
function createMutableArray<const T extends unknown[]>(items: T): [...T] {
return [...items]; // Spread removes readonly
}
const nums = createMutableArray([1, 2, 3]);
// nums: [1, 2, 3] — mutable tuple with literal types!Combining with Constraints
const works alongside extends constraints:
// Only accepts objects, preserves literal properties
function define<const T extends object>(value: T): T {
return value;
}
// Only accepts string arrays, preserves literal elements
function tags<const T extends string[]>(values: T): T {
return values;
}
const t = tags(["a", "b", "c"]);
// t: readonly ["a", "b", "c"]Edge Cases
Rest Parameters Don't Get const Treatment
function collect<const T extends unknown[]>(...items: T): T {
return items as T;
}
const result = collect(1, "two", true);
// ⚠️ result: [number, string, boolean]
// NOT [1, "two", true] — rest params don't preserve literalsThis is a known limitation. For rest parameters, you need explicit as const at the call site or a different approach.
Nested Objects Require Full const
function nested<const T>(value: T): T {
return value;
}
const deep = nested({
level1: {
level2: {
value: "deep"
}
}
});
// deep: { readonly level1: { readonly level2: { readonly value: "deep" } } }
// ✅ const applies recursively!When to Use const Type Parameters
- Route/path definitions: Type-safe routing needs literal path strings
- Action creators: Discriminated unions need literal
typefields - Configuration objects: When config values should be type-safe constants
- Builder APIs: Chained method calls that accumulate literal types
- Event systems: Type-safe event names and payloads
When NOT to Use
- Mutable data structures:
readonlywill fight your mutations - Dynamic values: If values are computed at runtime, literals don't help
- Public APIs: Consumers might not expect
readonlyreturns
The Takeaway
const type parameters are the declarative way to get as const inference without burdening callers. Use them in library code where literal preservation enables type-safe APIs—just be aware that readonly comes along for the ride.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement