| |

TypeScript 86 🔷 Const Type Parameters

TypeScript has always had a tension between what you want and what the compiler infers. You pass an array literal to a function, and TypeScript widens it to string[]. You want the literal values, so you add as const at the call site. That works — until you forget, or until you’re writing a library where every caller must remember the same incantation. The const modifier on type parameters, introduced in TypeScript 5.0, moves that responsibility from the caller to the declaration .

This chapter covers what const type parameters do, how they differ from as const, the readonly constraint rule that trips up most developers, and the advanced use cases where literal preservation cascades through overloaded APIs.

Key point: The const modifier on a type parameter tells the compiler to infer the narrowest possible type for arguments contextually typed by that parameter. String literals stay as their literal values. Array literals become readonly tuples. Object literals keep their literal properties. The effect is identical to having written as const at every call site, but the caller doesn’t need to know .


Why const type parameters exist

The as const assertion has been part of TypeScript since version 3.4. It solves a real problem: TypeScript widens literal types by default, and sometimes you need the narrow version.

The widening problem. When you write const names = ['John', 'Jake', 'Jack'], TypeScript infers string[], not readonly ['John', 'Jake', 'Jack'] . This is usually correct. The array can be mutated, so its element type should be broad. But when you pass that array to a function that needs to know the exact elements — for route definitions, event name mappings, or table column configurations — the widened type loses information .

The call-site ceremony problem. Before const type parameters, the only way to preserve literals through a function call was as const at every call site: routes(['home', 'about'] as const) . This works, but it’s fragile. If a caller forgets, the literal is silently widened and downstream inference fails. For a library author, requiring callers to remember as const is a design smell .

The cascade problem. The widening doesn’t stop at one level. If a function returns { path: string } instead of { path: '/users' }, any subsequent function that extracts parameters from that type sees string instead of '/users'. In overloaded fluent APIs, this cascading widening produces wrong overload selection, wrong return types, and useless autocomplete several layers down .

The declaration-side solution. const type parameters let the function author declare the intent once: function route<const Path extends string>(path: Path). Now every caller gets literal inference automatically. The caller doesn’t need to know about as const. The library API “just works” .

The trade-off. const type parameters are not free. They mark the inferred types as readonly, which can cause surprising type errors when the caller expects mutable types. They have no effect when the argument is a variable rather than a literal expression — passing const x = [1, 2, 3]; foo(x) does not infer a tuple . And they add syntax noise to functions where literal preservation isn’t actually needed .


a. The Basic Syntax and Effect

The const modifier goes directly before the type parameter name:

function identity<const T>(value: T): T {
  return value;
}

const a = identity(['r', 's']);  // readonly ['r', 's']
const b = identity({ foo: 'bar' });  // { readonly foo: "bar" }

Without const, identity(['r', 's']) would infer string[]. With const, it infers readonly ['r', 's'] . The readonly modifier is the key difference: the inferred tuple is immutable, so methods like push are not available on it.

The effect applies to string, numeric, and boolean literals, array literals, and object literals . A string argument 'a' infers as the literal type "a". An array ['a', ['b', 'c']] infers as readonly ["a", readonly ["b", "c"]]. An object { a: 1, b: "c" } infers as { readonly a: 1; readonly b: "c" }.

The const modifier is permitted on type parameters of functions, methods, and classes. It is not permitted on interfaces or type aliases . This is a deliberate limitation: the feature depends on contextual typing of argument expressions, which only exists at call sites.


b. The readonly Constraint Rule

The most common mistake when adopting const type parameters is constraining the type parameter to a mutable array type.

// ❌ Wrong — inference fails silently
function createRoute<const T extends string[]>(methods: T, path: string) {
  return { methods, path };
}

const route = createRoute(["GET", "POST"], "/users");
// route.methods type: string[] — widened!

The problem is that a const type parameter constrained to string[] wants to infer readonly ["GET", "POST"] — a readonly tuple. But a readonly tuple is not assignable to string[], because readonly arrays have fewer known methods. The inference fails, and TypeScript falls back to the constraint, producing string[] .

The fix is to include readonly in the constraint:

// ✅ Correct — readonly in the constraint
function createRoute<const T extends readonly string[]>(methods: T, path: string) {
  return { methods, path };
}

const route = createRoute(["GET", "POST"], "/users");
// route.methods type: readonly ["GET", "POST"]

The PR that implemented const type parameters states this explicitly: “When a const type parameter is constrained to an array type, that array type should include a readonly modifier; otherwise, inferences for the type parameter will fail to meet the constraint” . This is the single most important rule to remember.

The same applies to varargs. function insertIf<const V extends any[]>(...) fails to capture the tuple. function insertIf<const V extends readonly any[]>(...) works .

ConstraintPlain ['a','b']With as const
T extends string[]string[]["a", "b"]
T extends readonly string[]string[]readonly ["a", "b"]
const T extends string[]["a", "b"] (fails silently)["a", "b"]
const T extends readonly string[]readonly ["a", "b"]readonly ["a", "b"]

The table shows the interaction. The const modifier is what preserves literals. The readonly constraint is what makes the inferred readonly tuple assignable to the constraint .


c. Advanced Use Cases: Overloads and Cascading Inference

The basic use case — preserving literals in a single function call — is straightforward. The advanced use case is where literal preservation decides which overload fires next.

Consider an API where a method name determines what the next call can do:

type RouteMethods = 'GET' | 'POST';

function endpoint<const M extends RouteMethods>(method: M): Builder<M>;
function endpoint(method: RouteMethods): Builder<RouteMethods>;
function endpoint(method: RouteMethods): Builder<RouteMethods> {
  return {} as any;
}

interface Builder<M extends RouteMethods> {
  body: M extends 'POST' ? (schema: unknown) => Builder<M> : never;
  handler: (h: M extends 'GET' ? () => Response : (body: unknown) => Response) => void;
}

endpoint('POST').body(schema).handler(body => new Response());  // POST path
endpoint('GET').handler(() => new Response());                    // GET path

Without const, 'POST' widens to RouteMethods, both conditional branches resolve to unknown, and the user sees never everywhere. With const, the literal 'POST' is preserved, the conditional type resolves correctly, and the API works as designed .

The three places const T pays off most :

  1. Tuples passed positionally — const T extends readonly unknown[] keeps positions and length alive.
  2. Path strings driving downstream inference — a route path like '/users/:id' can be parsed by subsequent conditional types if the literal is preserved.
  3. Discriminator values in tagged unions — keeping the discriminant narrowed allows downstream conditional types to resolve correctly.

When not to apply it :

  • Generic functions whose return doesn’t depend on the literal value. const adds noise for no benefit.
  • When the caller deliberately passes a runtime value (route(req.path)). The literal-preservation request is silently ignored, but the noise remains.

Complete Example Session

This session builds a route-definition API that preserves literal types through every layer.

// ============================================
// PART 1: THE WIDENING PROBLEM
// ============================================

function routeWidened<Path extends string>(path: Path): { path: Path } {
  return { path };
}

const r1 = routeWidened('/users');
// r1: { path: string } — widened

const r2 = routeWidened('/users' as const);
// r2: { path: "/users" } — works, but requires as const

// ============================================
// PART 2: THE CONST TYPE PARAMETER
// ============================================

function route<const Path extends string>(path: Path): { path: Path } {
  return { path };
}

const r3 = route('/users');
// r3: { path: "/users" } — literal preserved automatically

// ============================================
// PART 3: THE READONLY CONSTRAINT RULE
// ============================================

// ❌ Wrong — fails silently
function badRoutes<const T extends string[]>(methods: T) {
  return methods;
}

const bad = badRoutes(['GET', 'POST']);
// bad: string[] — widened

// ✅ Correct — readonly in constraint
function goodRoutes<const T extends readonly string[]>(methods: T) {
  return methods;
}

const good = goodRoutes(['GET', 'POST']);
// good: readonly ["GET", "POST"]

// ============================================
// PART 4: OBJECT LITERAL INFERENCE
// ============================================

function defineConfig<const T extends Record<string, string>>(config: T): T {
  return config;
}

const config = defineConfig({
  home: '/',
  user: '/u/:id',
});
// config: { readonly home: "/"; readonly user: "/u/:id" }

type RouteKey = keyof typeof config;
// "home" | "user"

// ============================================
// PART 5: THE OVERLOAD-DISAMBIGUATION USE CASE
// ============================================

type Method = 'GET' | 'POST';

function endpoint<const M extends Method>(method: M): Builder<M>;
function endpoint(method: Method): Builder<Method>;
function endpoint(method: Method): Builder<Method> {
  return {} as any;
}

interface Builder<M extends Method> {
  body: M extends 'POST' ? (schema: unknown) => Builder<M> : never;
  handler: (h: M extends 'GET' ? () => Response : (body: unknown) => Response) => void;
}

// POST path: body is available
endpoint('POST').body({ type: 'object' }).handler((body) => new Response());

// GET path: body is never, handler takes no arguments
endpoint('GET').handler(() => new Response());

// ============================================
// PART 6: THE VARIABLE TRAP
// ============================================

const methods = ['GET', 'POST'];
const widened = goodRoutes(methods);
// widened: string[] — const has no effect on variables

// The const modifier only affects literal expressions at the call site.

// ============================================
// PART 7: THE VARARGS CASE
// ============================================

// ❌ Fails to capture tuple
function insertIfBad<const V extends any[]>(condition: boolean, ...value: V): V | readonly [] {
  return condition ? value : [];
}

// ✅ Works with readonly constraint
function insertIf<const V extends readonly any[]>(
  condition: boolean,
  ...value: V
): V | readonly [] {
  return condition ? value : [];
}

const x = insertIf(true, { s: 'a' }, { s: '?' });
// x: readonly [] | readonly [{ readonly s: "a"; }, { readonly s: "?"; }]

// ============================================
// PART 8: REMOVING READONLY WITH MAPPED TYPES
// ============================================

type DeepWritable<T> = T extends object
  ? { -readonly [P in keyof T]: DeepWritable<T[P]> }
  : T;

function identity<const T>(value: T): DeepWritable<T> {
  return value as DeepWritable<T>;
}

const obj = identity({ foo: { bar: 'baz' } });
// obj: { foo: { bar: string } } — readonly stripped

// ============================================
// PART 9: WHEN NOT TO USE CONST
// ============================================

// Generic function where literal doesn't matter
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

// const adds noise for no benefit
// function first<const T>(arr: T[]): T | undefined

// ============================================
// PART 10: THE PRACTICAL LIBRARY PATTERN
// ============================================

function select<const TOptions extends readonly [string, ...string[]]>({
  id,
  options,
}: {
  id: string;
  options: TOptions;
}): { id: string; options: TOptions } {
  return { id, options };
}

const status = select({ id: 'status', options: ['draft', 'published'] });
// status.options: readonly ["draft", "published"]
// status.options[number]: "draft" | "published"

The ten parts cover the widening problem, the basic const type parameter, the readonly constraint rule, object literal inference, overload disambiguation, the variable trap, the varargs case, removing readonly with mapped types, when not to use const, and the practical library pattern.


Quick Reference

The Syntax

FormEffect
<const T>(arg: T)Infer narrowest type for arg
<const T extends string>(arg: T)Literal string preserved
<const T extends readonly string[]>(arg: T)Readonly tuple preserved
<const T extends string[]>(arg: T)Inference fails, falls back to string[]

The Inference Behavior

InputPlain Tconst T
'a'string"a"
['a', 'b']string[]readonly ["a", "b"]
{ x: 'a' }{ x: string }{ readonly x: "a" }

The Constraint Interaction

Constraintconst Inferred TypeAssignable?
string[]readonly ["a"]❌ No
readonly string[]readonly ["a"]✅ Yes
any[]readonly [...]❌ No
readonly any[]readonly [...]✅ Yes

The When to Use

ScenarioUse const?
Route/path definition✅ Yes
Event name mapping✅ Yes
Table column config✅ Yes
Generic utility where literal doesn’t matter❌ No
Function that mutates the input❌ No
Caller passes runtime value❌ No

The Limitations

LimitationExplanation
Variables not affectedconst x = ['a']; foo(x) doesn’t infer tuple
Not on interfacesOnly functions, methods, classes
Always readonlyInferred tuples are immutable
No arrow function supportMust use function declaration

Best Practices

✅ Do This:

// Use const for functions that preserve literal types
function route<const Path extends string>(path: Path) { ... } // ✅
// Include readonly in array constraints
function define<const T extends readonly string[]>(items: T) { ... } // ✅
// Use const for configuration APIs
function table<const T extends readonly Field[]>(config: T) { ... } // ✅
// Document when const has no effect (variables)
// const x = ['a']; table(x) — widened

❌ Don’t Do This:

// Don't constrain to mutable array with const
function bad<const T extends string[]>(items: T) { ... } // ❌ fails silently
// Don't use const when literal doesn't matter
function first<const T>(arr: T[]): T | undefined { ... } // ❌ noise
// Don't expect const to work on variables
const x = ['a']; identity(x) // ❌ widened
// Don't use const on interfaces or type aliases
// interface Foo<const T> { ... } // ❌ error

Common Pitfalls

PitfallWhy It HappensFix
Literals still widenedConstraint missing readonlyUse readonly string[]
readonly breaks mutationconst always infers readonlyUse -readonly mapped type
const has no effectArgument is a variablePass literal expression
Overload selection wrongLiteral widened at call siteAdd const to type parameter
Error on interfaceconst not allowed on interfacesMove to function/method/class

Real-World Examples

1. Basic Literal Preservation

function identity<const T>(value: T): T { return value; }
const a = identity(['r', 's']); // readonly ['r', 's']

2. Route Definition

function route<const Path extends string>(path: Path): { path: Path } {
  return { path };
}
const r = route('/users'); // { path: "/users" }

3. Readonly Constraint

function createRoute<const T extends readonly string[]>(methods: T) { ... }

4. Object Literal Config

function defineConfig<const T extends Record<string, string>>(config: T): T {
  return config;
}

5. Overload Disambiguation

function endpoint<const M extends Method>(method: M): Builder<M>;

6. Varargs Capture

function insertIf<const V extends readonly any[]>(...value: V): V | readonly [] { ... }

7. Table Column Definition

function table<const TFields extends readonly Field[]>(config: TFields) { ... }

8. Select Options

function select<const TOptions extends readonly [string, ...string[]]>({ options }: { options: TOptions }) { ... }

9. Removing readonly

type DeepWritable<T> = { -readonly [P in keyof T]: DeepWritable<T[P]> };

10. Variable Trap

const methods = ['GET', 'POST'];
// identity(methods) — const has no effect

Visual

The Widening vs Const Inference

┌──────────────────────────────────────────────┐
│  WIDENING vs CONST                           │
│                                              │
│  Without const:                              │
│  identity(['r', 's'])                        │
│    └─ inferred: string[]                     │
│                                              │
│  With const:                                 │
│  identity<const T>(['r', 's'])               │
│    └─ inferred: readonly ['r', 's']          │
│                                              │
│  The const modifier acts like as const       │
│  at every call site, declared once.          │
│                                              │
└──────────────────────────────────────────────┘

The Readonly Constraint Rule

┌──────────────────────────────────────────────┐
│  READONLY CONSTRAINT                         │
│                                              │
│  ❌ T extends string[]                       │
│    └─ const wants readonly ['a', 'b']        │
│    └─ not assignable to string[]             │
│    └─ falls back to string[]                 │
│                                              │
│  ✅ T extends readonly string[]              │
│    └─ const infers readonly ['a', 'b']       │
│    └─ assignable to readonly string[]        │
│    └─ literal preserved                      │
│                                              │
│  Always include readonly in array constraints│
│                                              │
└──────────────────────────────────────────────┘

The Overload Disambiguation Flow

┌──────────────────────────────────────────────┐
│  OVERLOAD DISAMBIGUATION                     │
│                                              │
│  Without const:                              │
│  endpoint('POST')                            │
│    └─ 'POST' widens to Method                │
│    └─ both branches resolve to unknown       │
│    └─ body: never, handler: unknown          │
│                                              │
│  With const:                                 │
│  endpoint<const M>('POST')                   │
│    └─ 'POST' stays literal                   │
│    └─ M extends 'POST' resolves true         │
│    └─ body: (schema) => Builder              │
│    └─ correct overload fires                 │
│                                              │
└──────────────────────────────────────────────┘

The Variable Trap

┌──────────────────────────────────────────────┐
│  VARIABLE TRAP                               │
│                                              │
│  const methods = ['GET', 'POST'];            │
│  // methods: string[]                        │
│                                              │
│  identity(methods)                           │
│    └─ const T has no effect                  │
│    └─ argument is not a literal expression   │
│    └─ inferred: string[]                     │
│                                              │
│  identity(['GET', 'POST'])                   │
│    └─ argument is a literal expression       │
│    └─ inferred: readonly ['GET', 'POST']     │
│                                              │
│  const only affects literal expressions.     │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
IntroducedTypeScript 5.0
PurposeInfer narrowest type for generic arguments
Syntax<const T>
EffectSame as as const at every call site
Constraint ruleUse readonly in array constraints
LimitationNo effect on variables
ScopeFunctions, methods, classes only
ReadonlyAlways infers readonly tuples
Use caseRoute definitions, config APIs, overload disambiguation
Removal-readonly mapped type

Key takeaways:

  • const type parameters move as const from the call site to the declaration. Instead of requiring every caller to write routes(['home', 'about'] as const), the function author writes function routes<const T>(...) and every caller gets literal inference automatically .
  • The readonly constraint rule is mandatory for arrays. A const type parameter constrained to string[] fails silently because the inferred readonly tuple is not assignable to a mutable array. The constraint must be readonly string[] .
  • const has no effect on variables. The feature depends on contextual typing of literal expressions at the call site. Passing a variable that holds an array does not trigger literal inference .
  • The advanced use case is overload disambiguation. In fluent APIs where a method name determines which overload fires next, preserving the literal prevents the wrong overload from being selected and the wrong types from being produced .
  • The inferred types are always readonly. This is a consequence of the feature, not a bug. If you need mutable types, use a -readonly mapped type to strip the modifier .
  • const is not permitted on interfaces or type aliases. Only functions, methods, and classes can have const type parameters .
  • Use const when the return type depends on the literal value. Don’t use it for generic utilities where the literal doesn’t matter — it adds noise without benefit .

Remember: The const modifier on type parameters is a small syntax change with a large impact on library API design. It eliminates the as const ceremony that callers previously had to remember, and it preserves literal types through overloaded APIs where widening at one level cascades into wrong types several levels down. The one rule to internalize is the readonly constraint: when the type parameter is constrained to an array, the constraint must include readonly, or the inference fails silently and you’re back to widened types without any error to warn you.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!