EdgeCases Logo
Mar 2026
TypeScript
Expert
7 min read

TypeScript const Type Parameters

The const modifier on generics preserves literal types instead of widening—making type inference match your intent.

typescript
generics
const
literal-types
type-inference
advanced

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 literals

This 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 type fields
  • 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: readonly will fight your mutations
  • Dynamic values: If values are computed at runtime, literals don't help
  • Public APIs: Consumers might not expect readonly returns

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

Related Insights

Explore related edge cases and patterns

CSS
Surface
CSS color-mix() for Dynamic Theming
6 min
React
Deep
React Compiler Memoization Boundaries
7 min
Browser APIs
Expert
IndexedDB Transaction Auto-Commit: The Await Trap
8 min

Advertisement