DEV Community

Cover image for const Type Parameters: Preserving Literal Inference in Generic Functions
Gabriel Anhaia
Gabriel Anhaia

Posted on

const Type Parameters: Preserving Literal Inference in Generic Functions


You write a small helper that takes a list of route names and
returns them back, typed. You expect the type to be the exact
list you passed in. You get string[].

function routes<T extends string[]>(rs: T): T {
  return rs;
}

const r = routes(["home", "about", "settings"]);
// r: string[]
Enter fullscreen mode Exit fullscreen mode

You wanted ["home", "about", "settings"]. The compiler widened
it. So you reach for the fix everyone reaches for, and you ask
every caller to write as const.

const r = routes(["home", "about", "settings"] as const);
// r: readonly ["home", "about", "settings"]
Enter fullscreen mode Exit fullscreen mode

That works, but it pushes a burden onto the wrong side of the
call. The author of routes knows it wants narrow input. The
caller should not have to remember as const every single time,
and they will forget. TypeScript 5.0 moved that knowledge to
where it belongs: the type parameter.

The widening problem, stated plainly

Type inference has a default bias toward general types. When you
pass a string literal to a generic function, the compiler infers
string, not the literal. When you pass an array literal, it
infers an array, not a tuple. That bias is usually what you want.
A function that logs a value does not care whether the argument
was "hello" or some other string.

Functions that build types from their input care a great deal.
A router, a state machine, a schema builder, an event emitter:
these read structure out of the literal you hand them. Widening
erases that structure before the function can use it.

Before 5.0 you had two options, both bad. Option one: make every
caller append as const. Option two: rewrite the signature with
nested generic constraints that try to coax the inference into
staying narrow. The second option produces signatures that are
hard to read and easy to get wrong.

What const type parameters do

TypeScript 5.0 added a const modifier you place on the type
parameter itself.

function routes<const T extends readonly string[]>(
  rs: T,
): T {
  return rs;
}

const r = routes(["home", "about", "settings"]);
// r: readonly ["home", "about", "settings"]
Enter fullscreen mode Exit fullscreen mode

No as const at the call site. The const modifier tells the
compiler to infer the narrowest type for any argument bound to
T, exactly as if the caller had written as const themselves.
String literals stay literal. Arrays become readonly tuples.
Object properties stay narrow and pick up readonly.

The constraint changed from string[] to readonly string[]
for a reason. Once T infers a readonly tuple, a mutable
string[] constraint would reject it. Widen the constraint to
readonly string[] and the narrow tuple satisfies it.

That is the whole feature. One keyword, placed on the type
parameter, moves the as const burden off the caller and onto
the function author who actually knows it is needed.

A typed event emitter

Here is where it earns its place. Event emitters are a common
shape that lives or dies on literal inference. You register
handlers by event name, and you want the payload type to follow
from the name. Get the inference wrong and every emit call
takes any.

Start with the event map. The author of the emitter does not
know these names ahead of time; the consumer supplies them.

type Handler<P> = (payload: P) => void;

type EventMap = Record<string, unknown>;
Enter fullscreen mode Exit fullscreen mode

Now the emitter. Without const, the factory that builds it
would widen the consumer's event map and you would lose the
literal names. With const on the type parameter, the names stay
exact.

function createEmitter<const E extends EventMap>(
  schema: E,
) {
  const handlers: {
    [K in keyof E]?: Handler<E[K]>[];
  } = {};

  return {
    on<K extends keyof E>(
      name: K,
      fn: Handler<E[K]>,
    ): void {
      (handlers[name] ??= []).push(fn);
    },
    emit<K extends keyof E>(
      name: K,
      payload: E[K],
    ): void {
      handlers[name]?.forEach((fn) => fn(payload));
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The schema argument is a sample object whose property types
describe each event's payload. Call it like this:

const bus = createEmitter({
  login: { userId: "" },
  logout: { userId: "", reason: "" },
  pageView: { path: "" },
});
Enter fullscreen mode Exit fullscreen mode

Because the type parameter is const, E infers as the exact
object you passed, with login, logout, and pageView as
literal keys. Now the call site is fully checked.

bus.on("login", (p) => {
  p.userId; // string
});

bus.emit("login", { userId: "u_42" }); // ok

bus.emit("login", { reason: "x" });
// error: 'reason' does not exist; 'userId' is missing

bus.on("logot", () => {});
// error: '"logot"' is not assignable to
// "login" | "logout" | "pageView"
Enter fullscreen mode Exit fullscreen mode

Drop the const modifier and E widens to
Record<string, object>. Every event name becomes a plain
string, autocomplete on name disappears, and the payload
type collapses to object. The typo "logot" compiles clean.
The single keyword is the difference between a checked API and a
stringly-typed one.

Where const does and does not reach

The modifier infers narrowly, but it does not override an
explicit annotation. If a value already has a wide type before it
reaches the call, const cannot pull it back down.

const names = ["home", "about"]; // string[]
const r = routes(names);
// r: string[] — the variable was already widened
Enter fullscreen mode Exit fullscreen mode

names widened the moment it was declared, because a plain
const binding to an array literal infers string[]. By the
time it reaches routes, the literal shape is gone. The const
type parameter works on the expression passed at the call, not on
the history of a variable. Inline the literal and it stays
narrow; bind it to a widened variable first and it does not.

The modifier also stops at the boundary of an explicitly typed
parameter inside an object. If a property is annotated, that
annotation wins over the const inference for that property.

function take<const T>(x: T): T {
  return x;
}

const out = take({
  tag: "a",
  size: 10 as number,
});
// out: { tag: "a"; size: number }
Enter fullscreen mode Exit fullscreen mode

tag stays the literal "a". size is number because you
told the compiler so. const narrows what it can; it does not
fight an annotation you wrote on purpose.

One more boundary worth knowing: const on the type parameter
adds readonly to inferred arrays and tuples. If the function
body mutates that array, the body will not type-check against its
own readonly inference. That is a signal, not a bug. A function
that wants narrow literal input usually has no business mutating
it. If it must, take a mutable copy inside the body.

When to use it

Reach for a const type parameter when the function reads
structure out of its argument and hands that structure back into
the type system. The pattern shows up more than you would think:

  • Builders that derive a union or a tuple from a literal list, like the routes example or a defineConfig helper.
  • Schema and validation factories where the shape of the input object defines the output type.
  • Event emitters, command buses, and state machines keyed by literal names.
  • Any wrapper around useState-style tuple returns where you were already telling callers to add as const.

Skip it when the function does not care about the literal. A
logging helper, a function that takes a value and returns
void, anything that treats its argument as opaque: adding
const there only produces noisier inferred types for no gain,
and it can surprise callers who pass a mutable array and find it
inferred as readonly downstream.

The rule of thumb: if you ever found yourself writing
documentation that says "remember to pass this as const," that
function wants a const type parameter instead. Move the
requirement from the caller's memory into the signature, where
the compiler enforces it every time.

If this was useful

const type parameters are a small corner of the inference
machinery, and inference is where most of TypeScript's real
leverage lives. The TypeScript Type System works through that
machinery end to end — generics, the infer keyword, mapped and
conditional types — so signatures like the emitter above stop
feeling like guesswork and start reading as deliberate design.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)