From string literals to type-safe APIs
Template literal types combine string literal types into new literal types, turning loose string parameters into precise, checkable contracts. Event names, CSS class combinations, route paths, and configuration keys can all be validated at compile time, long before a wrong string causes a silent bug at runtime or a customer clicks a broken link.
Table of Contents
- 1. Template literal types: the basics
- 2. Union combinations: the combinatorial power of templates
- 3. Typed event names with Capitalize
- 4. Typed CSS class combinations
- 5. Typed route paths
- 6. Combining generics: type-safe string builders
- 7. Intrinsic string manipulation types
- 8. Inference with infer in template literal types
- 9. Template literal types compared side by side
- 10. Summary
- 11. FAQ
1. Template literal types: the basics
Since TypeScript 4.1, template literal types use the same backtick syntax as regular template strings, but they're evaluated at compile time instead of runtime. From type Name = "Mira" | "Timo" and a pattern like `Hello, ${Name}!`, a new literal type emerges that lists every combination of prefix, substituted literal, and suffix as a distinct member of a union. The compiler now knows not just that a value is a string, but exactly which concrete strings are valid at all.
The difference from a plain string parameter is the decisive advantage: a string accepts any sequence of characters at all, including typos, outdated event names, or malformed routes. A template literal type narrows the valid range of values precisely and lets the compiler react while the code is being written, not only during testing or in production.
2. Union combinations: the combinatorial power of templates
When multiple union types are substituted into a template literal type, the compiler automatically forms the cartesian product of all combinations. Two unions with three members each produce nine concrete string literals; three unions with four members each already produce 64. This combinatorial explosion is usually desirable, because it covers every valid combination exactly and without manually maintaining a list, for example for locale codes, configuration keys, or CSS class names.
In practice this means: instead of declaring type LocaleTag = string and only noticing a typo like "de_DE" instead of "de-DE" at runtime, TypeScript generates the complete, correctly spelled union automatically from the building blocks. For very large unions with several thousand combinations, keep an eye on type-checking time, since the compiler materializes every combination individually.
// Template literal types combine literal types into new literal unions
type Locale = "de" | "en" | "fr";
type Region = "DE" | "US" | "FR";
// Cross product: every Locale combined with every Region
type LocaleTag = `${Locale}-${Region}`;
// "de-DE" | "de-US" | "de-FR" | "en-DE" | "en-US" | "en-FR" | "fr-DE" | "fr-US" | "fr-FR"
type Greeting = `Hello, ${string}!`;
const greet = (message: Greeting): void => {
console.log(message);
};
greet("Hello, World!"); // OK
// greet("Hi, World!"); // Error: does not match the pattern
3. Typed event names with Capitalize
A classic use case for template literal types is event handler props following the pattern onClick, onChange, or onSubmit. Instead of maintaining every handler name individually as a string literal, you derive it from the event union with on${Capitalize<EventName>}. The intrinsic type Capitalize uppercases the first letter of each union member, and the template around it adds the on prefix, turning "click" | "focus" | "change" automatically into "onClick" | "onFocus" | "onChange".
Combined with a mapped type like { [K in EventHandlerName]?: (event: Event) => void }, this produces a props definition where every typo in a handler name is immediately flagged as a compile error: onClik instead of onClick no longer surfaces first in the browser. This derivation keeps the event union and the handler signature in sync at exactly one place.
// Typed event names using Capitalize<T> and template literals
type EventName = "click" | "focus" | "change" | "submit";
// Build handler prop names like onClick, onFocus, onChange, onSubmit
type EventHandlerName = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onChange" | "onSubmit"
type EventHandlers = {
[K in EventHandlerName]?: (event: Event) => void;
};
const handlers: EventHandlers = {
onClick: (event) => console.log("clicked", event.target),
onChange: (event) => console.log("changed", event.target),
// onClik: () => {}, // Error: not assignable, typo caught at compile time
};
// Generic helper: derive the handler prop name from any event union member
function makeHandlerName<E extends string>(event: E): `on${Capitalize<E>}` {
return `on${event.charAt(0).toUpperCase()}${event.slice(1)}` as `on${Capitalize<E>}`;
}
4. Typed CSS class combinations
In component-based design systems, such as those found in Hyvä themes with Tailwind CSS, CSS class names often follow a fixed schema of variant, size, and optional state, for example btn-primary-md or btn-danger-lg-hover. A template literal type built from the underlying union types Variant, Size, and State maps out exactly the set of valid class names and prevents a function from returning a nonexistent combination like btn-primary-xl.
The value of this pattern shows up especially in functions that assemble class names dynamically: the return type itself documents which classes can even be produced, and any refactor of the underlying unions automatically updates every dependent call site. For class strings assembled entirely from user input, static checking naturally has no effect, this pattern is suited to controlled, finite combinations, not arbitrary free text.
// Typed CSS class combinations for a design-system button component
type Size = "sm" | "md" | "lg";
type Variant = "primary" | "secondary" | "danger";
type State = "default" | "hover" | "disabled";
type ButtonClass =
| `btn-${Variant}-${Size}`
| `btn-${Variant}-${Size}-${Exclude<State, "default">}`;
function buttonClass(variant: Variant, size: Size, state: State = "default"): ButtonClass {
return state === "default"
? `btn-${variant}-${size}`
: `btn-${variant}-${size}-${state}`;
}
buttonClass("primary", "md"); // "btn-primary-md"
buttonClass("danger", "lg", "hover"); // "btn-danger-lg-hover"
// buttonClass("primary", "xl"); // Error: "xl" is not assignable to Size
5. Typed route paths
Route definitions are another area where string as a parameter type is too coarse. A path like /users/${string}/posts/${number} doesn't just describe that a string is expected, it describes that exactly two segments must follow at fixed positions and that the second one must be numeric. A navigation function using this type as its parameter rejects /users/mira/posts/abc already at compile time, because abc doesn't match a ${number} segment.
The infer keyword inside conditional types lets you go the other direction: from a route pattern like /users/:userId/posts/:postId, a recursive type automatically extracts an object with the parameter names as keys. That's the foundation of type-safe router libraries, where useParams() returns exactly the fields that actually occur in the given route string, without manually maintained parameter interfaces that drift from the real path over time.
// Typed route paths with parameter extraction via infer
type RoutePath = `/users/${string}/posts/${number}` | `/orders/${string}`;
function navigate(path: RoutePath): void {
window.history.pushState({}, "", path);
}
navigate("/users/mironsoft/posts/42"); // OK
// navigate("/users/mironsoft/posts/abc"); // Error: "abc" is not a number segment
// Extract typed params from a route pattern at the type level
type ExtractParams<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: Record<string, never>;
type UserPostParams = ExtractParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }
6. Combining generics: type-safe string builders
Template literal types show their full strength only in combination with generics. A generic type function like EnvKey<Prefix, Name> takes two generic string parameters and builds a new, precise literal type from them via ${Uppercase<Prefix>}_${Uppercase<Name>}. The return type is no longer string, but for example exactly "APP_DB_HOST", depending on the concrete literals passed in.
This pattern works especially well for build scripts, configuration objects, and type-safe wrappers around environment variables or API endpoints: a function readEnv(prefix, name) returns not just a value, but a type that precisely describes which key was read. The precondition is that the generic parameters are narrowly enough typed, for example with extends string, otherwise TypeScript widens them to string at the call site and the precision gain is lost.
// Intrinsic string manipulation types combined with a generic builder
type EnvKey<Prefix extends string, Name extends string> =
`${Uppercase<Prefix>}_${Uppercase<Name>}`;
type DbEnvKey = EnvKey<"app", "db_host">; // "APP_DB_HOST"
function readEnv<Prefix extends string, Name extends string>(
prefix: Prefix,
name: Name,
): EnvKey<Prefix, Name> {
return `${prefix.toUpperCase()}_${name.toUpperCase()}` as EnvKey<Prefix, Name>;
}
// Uncapitalize / Capitalize round trip for accessor names
type Getter<Field extends string> = `get${Capitalize<Field>}`;
type Setter<Field extends string> = `set${Capitalize<Field>}`;
type FieldFromGetter<G extends string> =
G extends `get${infer Field}` ? Uncapitalize<Field> : never;
type PriceField = FieldFromGetter<"getPrice">; // "price"
class Model<Field extends string> {
constructor(private data: Record<Field, unknown>) {}
}
7. Intrinsic string manipulation types
TypeScript provides four intrinsic types specifically for the type level: Uppercase<T> and Lowercase<T> convert a string literal type entirely to upper or lower case, Capitalize<T> uppercases only the first letter, Uncapitalize<T> lowercases only the first letter. Unlike custom conditional types, these four are implemented directly in the compiler, because character-by-character transformations can't be expressed cleanly and recursively within the regular type system.
Combined with template literal types, this yields symmetric pairs like Getter<Field> = `get${Capitalize<Field>}` and the reverse direction FieldFromGetter<G>, which uses infer and Uncapitalize to recover "price" from "getPrice". Such pairs form the basis for automatically generated getter/setter signatures in ORM-style model classes, without ever having to keep field name and method name manually in sync.
8. Inference with infer in template literal types
The infer keyword inside a conditional type lets you bind parts of a string literal type that match a template pattern and reuse them as standalone types. T extends `${infer Head}/${infer Tail}`, for example, splits a path at the first slash into a head and a remaining segment. Applying this pattern recursively, where the type calls itself with the remaining Tail, processes an arbitrary number of segments and effectively builds a complete parser at the type level, for example to split up routes, CSV headers, or SQL column lists.
Since TypeScript 4.5, the compiler supports tail-recursive conditional types with a substantially higher recursion depth before the error message Type instantiation is excessively deep appears. Caution is still warranted, though: for very long strings or heavily nested templates, type-checking time increases noticeably, and the compiler is not a full parser, complex grammars with backtracking still belong in runtime code, not in the type system.
9. Template literal types compared side by side
The table below shows, for typical scenarios, how much a loosely typed string parameter differs from the precision of a template literal type, and which class of error surfaces only at runtime versus already at compile time.
| Scenario | Naive (string) | Idiomatic (template literal type) | Benefit |
|---|---|---|---|
| Event handler prop | onClick: string |
on${Capitalize<EventName>} |
Typo in the handler name is rejected |
| CSS class | className: string |
btn-${Variant}-${Size} |
Invalid class combination is impossible |
| Route path | path: string |
/users/${string}/posts/${number} |
Wrong segment type is rejected |
| Environment variable | key: string |
EnvKey<Prefix, Name> |
Prefix/name convention is enforced |
| HTTP method + path | request: string |
${HttpMethod} ${RoutePath} |
Invalid method/path combination is impossible |
In all five scenarios, the difference isn't cosmetic: a naive string parameter pushes error detection into tests, code review, or worst case into production. A template literal type pushes that same error detection to the earliest possible moment, the moment the code is written, right in the editor.
Mironsoft
TypeScript architecture, type-level programming, and headless integrations
Want TypeScript types that actually prevent bugs?
We analyze existing TypeScript codebases, replace loose string typing with template literal types, and build type-safe build scripts, routers, and API clients for your Magento and Hyvä frontend stack.
Type audit
Reviewing existing type definitions for overly loose string typing
Refactoring
Introducing template literal types, generics, and intrinsic types where they count
Tooling
Type-safe build scripts, routers, and API clients for headless setups
10. Summary
Template literal types turn the lowest common denominator of all strings, the type string, into precise, checkable literal types. From union types like event names, CSS variants, or route segments, backtick syntax produces new unions that map out every valid combination exactly. The intrinsic types Uppercase, Lowercase, Capitalize, and Uncapitalize extend the system with character transformations that couldn't be expressed with regular conditional types.
Combined with generics and infer, template literal types become the foundation for type-safe string builders, router libraries, and configuration systems. The effort pays off wherever strings follow a fixed pattern, and it stays deliberately scoped to finite, controlled combinations, not arbitrary free text from user input.
Template Literal Types for Type-Safe Strings, the Essentials at a Glance
Basics
Backtick syntax at the type level: `Hello, ${Name}!` turns union types into precise literal-type unions instead of loose string.
Events & CSS classes
on${Capitalize<EventName>} and btn-${Variant}-${Size} catch typos already at compile time.
Routes & infer
/users/${string}/posts/${number} plus infer extracts typed parameters from route patterns.
Intrinsic types
Uppercase, Lowercase, Capitalize, Uncapitalize, implemented directly in the compiler, for character transformations.