Skip to Content
TypeScript Advanced: Types That Hold Up in Production
Advanced
0 likes
Courses
TypeScript Advanced: Types That Hold Up in Production

Template Literal Types for Routes and Event Names

Build string types from parts, rename keys with as and read pieces out of a string with infer, and see where a union grows too large. After this, a mistyped route or event name is a compile error.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If you already build string types with ${...} and know Capitalize, skip to "Rename keys with as", then continue.

A lot of bugs in a real codebase are strings that look right. A route written as "/user/42" instead of "/users/42". An event called "order:create" when the code listens for "order:created". TypeScript can already check that a value is one of a few literal strings. Template literal types go a step further: they build strings from parts, so the compiler knows the shape of the text and not only a fixed list.

A string with a number inside

The syntax is the same as a template string in JavaScript, written where a type goes.

type UserRoute = `/users/${number}`;

const ok: UserRoute = "/users/42";
const typo: UserRoute = "/user/42";
const notANumber: UserRoute = "/users/abc";
route_shape.ts(4,7): error TS2322: Type '"/user/42"' is not assignable to type '`/users/${number}`'.
route_shape.ts(5,7): error TS2322: Type '"/users/abc"' is not assignable to type '`/users/${number}`'.

Only text that starts with /users/ and ends with something that parses as a number is allowed. The first error is a typo in the fixed part. The second has the right fixed part and the wrong kind of value. You can put string, number, bigint, boolean and literal unions in the holes.

Unions multiply

Put a union in a hole and you get one string for every member. Put unions in two holes and you get every combination.

type Entity = "user" | "order";
type Action = "created" | "deleted";
type EventName = `${Entity}:${Action}`;

function on(event: EventName, handler: () => void): void {
  console.log("listening for", event);
  handler();
}

on("order:created", () => {});
on("order:create", () => {});
events.ts(11,4): error TS2345: Argument of type '"order:create"' is not assignable to parameter of type '"order:created" | "order:deleted" | "user:created" | "user:deleted"'.

EventName is four strings: "user:created", "user:deleted", "order:created" and "order:deleted". Add "invoice" to Entity and the type grows to six, with no list to update. The misspelled call fails, and the editor offers the valid names as you type.

⚠ Warning: The number of members is the product of the unions. Two unions of 10 give 100, three of 50 give 125,000, and the compiler stops past 100,000 members with error TS2590, "Expression produces a union type that is too complex to represent." Keep generated unions small and prefer a plain string for the open part.

Change the case of a part

Four built-in helpers change a string type: Uppercase, Lowercase, Capitalize and Uncapitalize.

type Action = "created" | "deleted";
type HandlerName = `on${Capitalize<Action>}`;

const names: HandlerName[] = ["onCreated", "onDeleted"];
console.log(names);
[ 'onCreated', 'onDeleted' ]

Capitalize<"created"> is "Created", so the whole type is "onCreated" | "onDeleted". The helpers work on literal types only. Capitalize<string> stays a loose string type.

Rename keys with as

This is where template types earn their place. A mapped type can rename each key with as, so you can derive an object of handlers from the list of events.

type Action = "created" | "deleted";

type Handlers = {
  [A in Action as `on${Capitalize<A>}`]: (id: number) => void;
};

const handlers: Handlers = {
  onCreated: (id) => console.log("created", id),
  onDeleted: (id) => console.log("deleted", id),
};

handlers.onCreated(7);
handlers.onDeleted(8);
created 7
deleted 8

id needs no annotation, because the property type comes from the mapped type. Add "archived" to Action and handlers stops compiling until onArchived exists. That is the same safety you get from a Record keyed by a union, applied to names that you did not want to type twice.

type Action = "created" | "deleted" | "archived";

type Handlers = {
  [A in Action as `on${Capitalize<A>}`]: (id: number) => void;
};

const handlers: Handlers = {
  onCreated: () => {},
  onDeleted: () => {},
};
handlers_missing.ts(7,7): error TS2741: Property 'onArchived' is missing in type '{ onCreated: () => void; onDeleted: () => void; }' but required in type 'Handlers'.

Take a string apart with infer

A template type can also be a pattern. Combine it with infer and you can read pieces out of a literal, the same way you read pieces out of a promise.

type Entity<E> = E extends `${infer Name}:${string}` ? Name : never;
type Verb<E> = E extends `${string}:${infer Name}` ? Name : never;

const entity: Entity<"order:created"> = "user";
const verb: Verb<"order:created"> = "deleted";
split.ts(4,7): error TS2322: Type '"user"' is not assignable to type '"order"'.
split.ts(5,7): error TS2322: Type '"deleted"' is not assignable to type '"created"'.

The first infer takes everything before the first colon, and the second takes what follows it. Parsing types out of strings gets popular quickly, and that is the point to slow down. A route parameter parser such as "/users/:id" is only a few more lines, and it is useful enough that it has its own tutorial. Keep it to routes and event names, which are short, and do not try to parse JSON or SQL at the type level.

Common mistakes

  • Using string where a pattern would do. on(event: string) accepts every typo you will ever make.
  • Building a union from three or four long lists and slowing the editor. Measure before you generalise.
  • Expecting the type to check the value at run time. The type is erased. Text that comes from a form or a URL is a plain string until you check it.

💡 Tip: Name the building blocks as separate types, like Entity and Action above. Then the same pieces feed the event names, the handler names and the payload map.

If you skip this, every string in your project stays a string, and the compiler can only check it when you write the full list by hand. Which strings in your codebase are really three or four values with a prefix in front?

About

Build string types from parts, rename keys with as and read pieces out of a string with infer, and see where a union grows too large. After this, a mistyped route or event name is a compile error.

Duration
5 min
Last update
09/29/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.