- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You write a small router helper. It takes an array of route
definitions and gives you back a typed navigate function that
will only accept paths from the list. The helper looks fine at
the desk.
const routes = defineRoutes([
{ path: "/users/:id", name: "user-detail" },
{ path: "/orders/:id", name: "order-detail" },
{ path: "/dashboard", name: "dashboard" },
]);
routes.navigate("user-detail", { id: "u_123" });
routes.navigate("order-detail", { id: "o_456" });
routes.navigate("dashboard");
routes.navigate("nope"); // you want this to fail
That last line compiles. The first three calls accept any
string at all. The compiler widened the array of object literals
the moment it crossed into your helper, and every literal in it
became a plain string. The whole point of the helper is gone.
The fix you reach for first is as const at the call site:
defineRoutes([...] as const). It works. It also asks every
caller of your helper to know about a TypeScript ceremony that
has nothing to do with what they are trying to do. For one
helper that is fine. For a library you ship to other teams, the
calls get noisy and the ones who forget the assertion file bug
reports about types that "do not work."
TypeScript 5.0 shipped a quiet feature for exactly this case.
It is called const type parameters. The syntax is one keyword
on the generic declaration. The effect is that the inferred
type of an argument is treated as if the caller had written
as const, without the caller having to write anything.
What const on a type parameter actually does
The TypeScript 5.0 announcement post introduced const type
parameters
under the heading "const Type Parameters." The syntax is a
const modifier in the type parameter list:
function tag<const T>(x: T): T {
return x;
}
const a = tag(["click", "key"]);
// a: readonly ["click", "key"]
const b = tag({ kind: "submit", priority: 2 });
// b: { readonly kind: "submit"; readonly priority: 2 }
Compare that with the non-const version. The same function
without the const modifier widens both calls:
function tagWide<T>(x: T): T {
return x;
}
const c = tagWide(["click", "key"]);
// c: string[]
const d = tagWide({ kind: "submit", priority: 2 });
// d: { kind: string; priority: number }
The const modifier tells the compiler to keep literal types
as literal, arrays as readonly tuples, and object properties
as readonly with their literal values. It is the same narrowing
behavior you get from writing as const at the call site,
applied automatically when the function captures T.
There is a piece of fine print. The const modifier only
takes effect when the argument has no contextual type pulling
inference the other way. If the parameter type is something
like string, the argument is widened back to string
regardless of const, because the parameter type is the
contextual type. The pattern fires when the parameter type is
the type variable itself, or a structural shape that contains
the variable.
function tagOnly<const T extends string>(x: T): T {
return x;
}
const e = tagOnly("hello");
// e: "hello"
The constraint extends string keeps the function honest about
what it accepts. The const keeps the inferred type narrow.
Both jobs in one signature.
Where it earns its keep
Three patterns earn the keyword. The shape is always the same:
a caller writes a literal, and the helper needs that literal
preserved so downstream code can read structure off it.
Route arrays
The motivating example from the top. With one keyword the
helper does what readers expected it to do.
type RouteDef = { path: string; name: string };
function defineRoutes<
const R extends readonly RouteDef[]
>(routes: R) {
type Name = R[number]["name"];
return {
navigate(name: Name, params?: Record<string, string>) {
const route = routes.find((r) => r.name === name);
if (!route) throw new Error(`unknown route: ${name}`);
return resolvePath(route.path, params ?? {});
},
};
}
R is the entire tuple of route definitions, with each
literal name preserved. R[number]["name"] is the union of
every name in the array. The navigate function only accepts
strings drawn from that union. The caller wrote a plain array.
The library wrote one keyword.
const routes = defineRoutes([
{ path: "/users/:id", name: "user-detail" },
{ path: "/orders/:id", name: "order-detail" },
{ path: "/dashboard", name: "dashboard" },
]);
routes.navigate("user-detail", { id: "u_123" }); // ok
routes.navigate("nope"); // error
resolvePath is the path-template formatter; it is not
interesting and is not the point of the example. The point is
that "the set of valid names" is derived from the array the
caller passed in, with no as const at the call site.
Factory APIs that surface caller literals
Another shape: a state-machine helper, a permissions registry,
a feature-flag set. Anything where the caller passes a list of
named items and expects to address them by literal name later.
function defineFlags<const F extends readonly string[]>(
flags: F
) {
return {
has(name: F[number]): boolean {
return flags.includes(name);
},
all(): F {
return flags;
},
};
}
const featureFlags = defineFlags([
"checkout-v2",
"new-billing",
"experimental-search",
]);
featureFlags.has("checkout-v2"); // ok
featureFlags.has("checkout-v3"); // error
The caller never wrote as const. The library captured the
literal tuple, derived F[number] for the union, and gave back
a typed surface that knows which strings are real.
Builder DSLs
Builders that chain calls to assemble a schema, a query, or a
plan benefit twice. The const modifier preserves literals at
each step, and the chain accumulates a precise type without
the caller annotating anything.
type Field<N extends string, T> = {
readonly name: N;
readonly type: T;
};
class SchemaBuilder<F extends readonly Field<string, unknown>[]> {
constructor(public fields: F) {}
add<const N extends string, T>(
name: N,
type: T
): SchemaBuilder<readonly [...F, Field<N, T>]> {
return new SchemaBuilder<readonly [...F, Field<N, T>]>([
...this.fields,
{ name, type },
]);
}
}
const schema = new SchemaBuilder([] as const)
.add("id", "string")
.add("createdAt", "date")
.add("amount", "number");
type Names = typeof schema.fields[number]["name"];
// "id" | "createdAt" | "amount"
Every call to add captures the new name as a literal. The
final fields tuple type carries the full list. A consumer
that reads field names off the schema gets the exact union
the caller built.
Where it stops working
const T is shallower than people expect. The compiler
preserves literals along the structural path that the type
parameter captures. It does not deeply rewrite every nested
shape into a readonly literal one without help.
The most common surprise: an array of objects whose property
types are not directly visible in the parameter signature.
The outer tuple narrows. The inner property types behave like
they always did.
function take<const T>(x: T): T {
return x;
}
const r = take([
{ kind: "click", x: 1 },
{ kind: "key", x: 2 },
]);
// r: readonly [
// { readonly kind: "click"; readonly x: 1 },
// { readonly kind: "key"; readonly x: 2 }
// ]
That looks fine. The footgun appears when a value is built
elsewhere and passed in:
const events = [
{ kind: "click", x: 1 },
{ kind: "key", x: 2 },
];
const s = take(events);
// s: { kind: string; x: number }[]
The const modifier acts on the argument expression, not on
the variable that flows into it. By the time events reaches
take, its type is already { kind: string; x: number }[].
as const would not have helped either; the literal was lost
at assignment.
For dynamically built values, the answer is the constructor
itself. Either build the value inside the helper, or accept a
function that returns the literal so the literal is built at
the boundary.
Pairing with DeepReadonly for full literal preservation
When the helper signature uses object types directly, deep
narrowing is automatic. When the helper accepts an arbitrary
shape and you need its nested arrays and objects locked down,
add a DeepReadonly mapped type and apply it at the boundary.
type DeepReadonly<T> = T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
function freeze<const T>(x: T): DeepReadonly<T> {
return x as DeepReadonly<T>;
}
const config = freeze({
retries: 3,
endpoints: {
auth: "/v1/auth",
users: "/v1/users",
},
methods: ["GET", "POST"],
});
// config: DeepReadonly<{
// retries: 3;
// endpoints: { auth: "/v1/auth"; users: "/v1/users" };
// methods: readonly ["GET", "POST"];
// }>
This is a type-level lock only. The runtime object is still
mutable; reach for Object.freeze if you need the runtime
guarantee too.
The const keeps the surface narrow. DeepReadonly walks the
shape and marks every node as readonly. The combination is what
people often expect as const to do alone, applied at the
helper instead of at every call site.
When to reach for it
Three signs that const type parameters are the right answer:
- The helper takes a literal-shaped argument (an array of objects, a config object, a tuple of names) and the caller expects the literal to flow through.
- You currently ask callers to write
as const, or you are about to. - The helper produces a typed surface (a
navigate, ahas, aparse) keyed off positions, names, or property values in the argument.
And the cases where it is not what you need:
- The argument is already widened (built in a
letor imported from another module). - The function never reads the literal back; it only validates the shape. A plain generic with constraints is enough.
- The helper accepts a primitive whose contextual type is
itself a primitive. The
constdoes not help when the parameter isstringornumber.
The pattern is library-author tooling more than application
code. Most call sites do not need it. The places that do are
the helpers that call sites use repeatedly, where shaving the
as const off a hundred calls compounds into a real
ergonomic win.
Closing the loop
The router helper from the lead paragraph now does what its
shape promised. The caller writes a plain array of route
definitions. The helper captures the tuple, derives the union
of names, and refuses any string that is not in the list.
const routes = defineRoutes([
{ path: "/users/:id", name: "user-detail" },
{ path: "/orders/:id", name: "order-detail" },
{ path: "/dashboard", name: "dashboard" },
]);
routes.navigate("user-detail", { id: "u_123" }); // ok
routes.navigate("nope"); // error
One keyword on the generic declaration replaced an as const
at every call site, plus the support thread when a teammate
forgot to write it. That is the trade const type parameters
were designed for, and it is one of the cleanest small wins in
TypeScript 5.x.
If this was useful
Const type parameters are one of the smaller pieces The
TypeScript Type System
covers in the chapter on generics and inference, alongside the
bigger machinery (mapped types, conditional types, infer,
template literals) that lets you build library-grade APIs in
TypeScript. If the router helper above made sense and you want
the same depth of treatment for the rest of the type system's
surface area, that is the book. It sits inside The TypeScript
Library, a
5-book set covering essentials, the type system, JVM and PHP
bridges, and production tooling.

Top comments (0)