TypeScript Template Literal Types: The Feature That Replaces Half Your Generic Magic
Most TypeScript users have heard of template literal types and don't use them. They are the feature that turns "string with a specific shape" into a checked type. Here are the four practical patterns — typed routes, prefixed keys, builder methods, validation — that show up in every real codebase.
The team has a typed router. The route definitions look like:
type Routes =
| '/users'
| '/users/:id'
| '/users/:id/orders'
| '/users/:id/orders/:orderId'
| // ... 80 more lines ...
When somebody adds a new route, they update this union manually. Half the time they forget. The runtime registers the route fine; the typechecker doesn’t know it exists. Autocomplete goes stale.
Template literal types are the TypeScript feature that lets you compute string types from other types. Instead of an 80-line union, the compiler can infer '/users/:id/orders/:orderId' from the route registration. Add a route, get autocomplete; remove a route, get a type error at every callsite.
This post is the four patterns that show up in real codebases, with working code and the perf implications.
The basic syntax
A template literal type uses backticks just like JS template strings, but with types instead of values:
type Greeting = `Hello, ${string}!`;
const a: Greeting = 'Hello, world!'; // ok
const b: Greeting = 'Hi there'; // error
That’s it. The type is a string with an enforced shape. Replace string with a more specific type to constrain further:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `${HTTPMethod} ${string}`;
const a: Endpoint = 'GET /users'; // ok
const b: Endpoint = 'PATCH /users'; // error
Pattern 1: Typed routes
Build the route union from the registered routes — you don’t write it manually:
const routes = {
'/users': () => listUsers(),
'/users/:id': ({ id }: { id: string }) => getUser(id),
'/users/:id/orders': ({ id }: { id: string }) => listOrders(id),
'/users/:id/orders/:orderId': ({ id, orderId }: { id: string; orderId: string }) => getOrder(id, orderId),
} as const;
type Path = keyof typeof routes;
// ^? '/users' | '/users/:id' | '/users/:id/orders' | '/users/:id/orders/:orderId'
Now Path is the auto-derived union. Adding a new entry to routes adds it to Path automatically.
Pattern 2: Extracting params from a path
This is where template literals shine — extracting :id and :orderId out of a path string and producing a typed params object.
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: Path extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type P1 = ExtractParams<'/users/:id'>;
// ^? { id: string }
type P2 = ExtractParams<'/users/:id/orders/:orderId'>;
// ^? { id: string; orderId: string }
Use this to generate typed handler signatures:
function defineRoute<Path extends string>(
path: Path,
handler: (params: ExtractParams<Path>) => unknown,
) {
// implementation...
}
defineRoute('/users/:id/orders/:orderId', ({ id, orderId }) => {
// id and orderId are both typed as string. Autocomplete works.
});
Pattern 3: Prefixed keys
A common pattern is “all keys in this object start with a known prefix”:
type Prefixed<T, Prefix extends string> = {
[K in keyof T as `${Prefix}${string & K}`]: T[K];
};
type Original = { foo: number; bar: string };
type WithPrefix = Prefixed<Original, 'data_'>;
// ^? { data_foo: number; data_bar: string }
Useful for HTML data attributes, Tailwind variant generation, prefixed environment variable types.
The trick is as in the mapped type, which lets you transform the key.
Pattern 4: Builder method names
A setX, getX builder pattern from a type:
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (v: T[K]) => Setters<T>;
};
type User = { id: string; name: string; age: number };
type UserBuilder = Setters<User>;
// ^? { setId: (v: string) => UserBuilder; setName: (v: string) => UserBuilder; setAge: (v: number) => UserBuilder }
Capitalize is one of TypeScript’s built-in string helpers. Others: Lowercase, Uppercase, Uncapitalize.
Note that this only types the builder; you still need a runtime implementation. Most cases use a runtime Proxy that dispatches setX to setting x.
Pattern 5: Validation at the type level
You can encode some shape constraints in the type system:
type Email = `${string}@${string}.${string}`;
const a: Email = 'alice@example.com'; // ok
const b: Email = 'not-an-email'; // error: missing @
This is not a substitute for runtime validation — many invalid emails pass this check (a@b.c does too). But it catches the obvious mistakes at compile time, before they get to runtime validators like Zod.
Performance considerations
Template literal types can be slow. The TypeScript compiler computes them eagerly, and a complex one (recursive, with many alternatives) can blow up.
Specifically:
- Avoid deeply recursive parsing. A type that recursively breaks down a string into components can hit recursion depth limits and slow your IDE.
- Avoid huge unions. A template literal that produces a union of 10,000 strings will tax everything.
- Cap recursion explicitly. Use a depth-limit type to bound recursion:
type ExtractParams<Path extends string, Depth extends number = 10> =
Depth extends 0 ? {} :
Path extends `${string}:${infer P}/${infer Rest}`
? { [K in P]: string } & ExtractParams<Rest, Decrement<Depth>>
: Path extends `${string}:${infer P}` ? { [K in P]: string } : {};
For real codebases, the limits rarely matter. But if your IDE is slow on a file using template literals, look here first.
Where this stops working
Template literal types are string-level manipulation. A few things they cannot do:
- Decode JSON. A literal type cannot parse arbitrary JSON; it can only enforce shapes.
- Compute arithmetic. No “five-digit number” type. Can pattern-match digits, can’t do math.
- Replace a real parser. For things like SQL, GraphQL, regex — use a code generator at build time, not template literals at type-check time.
For lightweight string-shape constraints, template literals are the right tool. For anything more complex, generate code.
Real-world libraries using this
A few notable examples to study:
- type-fest — many template-literal-based utilities (
KebabCase,CamelCase,Get). - Tailwind CSS types — variant string types (
hover:bg-red-500,md:text-lg) computed via template literals. - tRPC / Hono — typed route params extracted from string paths.
- Drizzle ORM — typed SQL queries, table column types.
Reading their type definitions is a faster way to learn the patterns than abstract examples.
When to reach for them
A reasonable rule of thumb:
- A small number of stringly-typed parameters? Use a union of literal strings.
- Strings with a known structure derived from another type? Template literal.
- A general parser? Generate code at build time; don’t use the type system.
Most teams under-use template literals out of unfamiliarity. They are not advanced TypeScript magic — they are the feature that catches the typo before runtime.
The takeaway
Template literal types turn string manipulation into type-checked operations. Four patterns — typed routes, parameter extraction, prefixed keys, builder methods — cover most real use cases. They are not for arbitrary parsing; they are for capturing the shape of strings you already work with.
The next time you find yourself maintaining a hand-written union of string literals, ask whether a template literal could derive it. The result is code that gets safer as the team adds new entries instead of going stale.
A note from Yojji
The kind of TypeScript fluency that turns string-based APIs into fully typed contracts — without runtime cost — is the kind of detail Yojji’s frontend and backend teams put into the codebases they hand back to clients.
Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and full-cycle product engineering — including the type-system work that decides whether a codebase grows safely or accumulates fragile shortcuts.