Template Literal Types for Type-Safe Strings
AI generated
<T>
type
TypeScript · Template Literal Types · Generics · Type Safety
Template Literal Types for Type-Safe Strings
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.

14 min. read Template Literal Types · Capitalize · Uppercase TypeScript 4.1+ · Type-Level Programming

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.

11. FAQ: Template Literal Types for Type-Safe Strings

1What is a template literal type in TypeScript?
Backtick syntax at the type level: a prefix, substituted literal types, and a suffix produce a new string literal type or a union of them, evaluated at compile time.
2Since which TypeScript version do template literal types exist?
Since TypeScript 4.1, together with the intrinsic types Uppercase, Lowercase, Capitalize, and Uncapitalize.
3What is the difference between Capitalize and Uppercase?
Capitalize uppercases only the first letter, leaving the rest unchanged. Uppercase converts the whole string. Uncapitalize and Lowercase are the counterparts.
4How does a template literal type emerge from multiple union types?
The compiler automatically forms the cartesian product of every substituted union member as a new union of string literals.
5Can I extract route parameters in a type-safe way?
Yes, using infer in a conditional type. A recursive type breaks down a pattern like /users/:userId/posts/:postId into an object with the parameter names as keys.
6What does infer do in a template literal type?
infer binds a substring matching the template pattern to a new type name, which can then be used further in the conditional type.
7How many combinations are reasonable at most?
No fixed limit, but every combination gets materialized. With several thousand combinations, type-checking time increases noticeably and instantiation limits can be hit.
8Do template literal types exist at runtime?
No, they only exist at compile time and are fully stripped away when transpiling to JavaScript.
9How do I combine template literal types with generics?
A generic type like EnvKey<Prefix, Name> builds a precise return type from generic string parameters via ${Uppercase<Prefix>}_${Uppercase<Name>}.
10When should I stick with string?
For free text coming entirely from user input without a fixed pattern. Template literal types pay off for controlled, finite combinations.