Ch. 2 · TypeScript

TypeScript Template Literal Types

Build string types from unions, parse route parameters with infer, see what ${number} really accepts, and know where these types stop helping.

~6 min readintermediateupdated Oct 19, 2026

Template literal types use the same backtick syntax as JavaScript template strings, but they work on types. type Greeting = `hello ${string}` is the set of all strings that start with “hello “. On its own that’s a curiosity. Combined with unions and infer, it lets TypeScript check things that used to be plain strings: CSS class names, event names, route patterns, environment variable keys. Interviewers ask about it to see whether you know both the power and the limits, because it’s easy to build types that look clever and check nothing.

Unions multiply

When you interpolate a union, TypeScript produces every combination:

type Size = 'sm' | 'md' | 'lg';
type Side = 'top' | 'right' | 'bottom' | 'left';
type Spacing = `m${'t' | 'r' | 'b' | 'l'}-${Size}`;
const a: Spacing = 'mt-sm';
const b: Spacing = 'mx-sm';
TypeScript
spacing.ts(5,7): error TS2820: Type '"mx-sm"' is not assignable to type '"mb-lg" | "mb-md" | "mb-sm" | "ml-lg" | "ml-md" | "ml-sm" | "mr-lg" | "mr-md" | "mr-sm" | "mt-lg" | "mt-md" | "mt-sm"'. Did you mean '"mb-sm"'?
Text

Four prefixes times three sizes gives twelve members, and the error message lists them all and suggests the closest. That’s a good fit for a design system’s utility classes: the set is finite, small and worth enumerating.

It doesn’t scale forever. Each extra interpolated union multiplies the count, and TypeScript caps union size at 100,000 members. Four digits is fine; five is not:

type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type Four = `${Digit}${Digit}${Digit}${Digit}`;
type Five = `${Digit}${Digit}${Digit}${Digit}${Digit}`;
TypeScript
digits.ts(3,13): error TS2590: Expression produces a union type that is too complex to represent.
Text

Line 3 is Five. Before you hit that error, very large unions already make editors slow, because every assignment check compares against all members. When the set is effectively infinite, use a pattern instead of an enumeration.

Patterns: ${string} and ${number}

A template literal type with string or number in it isn’t expanded; it’s checked as a pattern. `${number}px` describes every string that is a number followed by px:

type Px = `${number}px`;
const w1: Px = '12px';
const w2: Px = '1.5px';
const w3: Px = '-4px';
const w4: Px = '1e3px';
const w5: Px = '12 px';
const w6: Px = 'px';
TypeScript

Only w6 failed:

px.ts(7,7): error TS2322: Type '"px"' is not assignable to type '`${number}px`'.
Text

'1e3px' and '12 px' both passed. A follow-up file showed that ' 12px' and '0x1Fpx' pass as well, while 'NaNpx', 'Infinitypx' and '12pxpx' fail. The rule is roughly “the part before px is a string JavaScript would read as a finite number”, and JavaScript’s Number(' 12 ') is 12 because it trims whitespace. So ${number} is not the same as “digits”. If you’re validating user-entered CSS, you still need a runtime check with a regular expression. The type is good for catching typos in your own source code, such as '12' without a unit.

Literal expressions and widening

When you build a string with a template expression, its type depends on how you declare it. In TypeScript 7.0, const cls = `btn-${size}` (with size a const holding 'md') has the literal type 'btn-md', but let x = cls widens to string. Interpolating anything typed string gives string. If you need a literal type to survive into a let or an object property, add as const.

Parsing with infer: typed route parameters

The feature becomes really useful with infer, because a template literal type can match a string and capture pieces of it. Here’s a helper that fills in a route pattern like /orgs/:orgId/users/:userId and requires exactly the right parameters:

type RouteParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof RouteParams<`/${Rest}`>]: string }
    : Path extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

function buildPath<P extends string>(pattern: P, params: RouteParams<P>): string {
  return pattern.replace(/:([A-Za-z]+)/g, (_, name: string) => {
    const value = (params as Record<string, string>)[name];
    if (value === undefined) throw new Error(`missing param ${name}`);
    return encodeURIComponent(value);
  });
}

console.log(buildPath('/orgs/:orgId/users/:userId', { orgId: 'acme', userId: 'u 42' }));
console.log(buildPath('/health', {}));
TypeScript

It compiles under --strict, and Node prints:

/orgs/acme/users/u%2042
/health
Text

Walk through the type with '/orgs/:orgId/users/:userId'. The first branch matches: ${string} swallows /orgs/ (it stops at the first :), infer Param captures orgId (it stops at the first / after that), and infer Rest captures users/:userId. The type recurses on /users/:userId, which doesn’t contain a further / after the parameter, so the second branch matches and produces { userId: string }. The outer mapped type combines both keys. A path with no colon falls through to {}.

Mistakes now fail at the call site:

routes-bad.ts(8,41): error TS2741: Property 'userId' is missing in type '{ orgId: string; }' but required in type '{ orgId: string; userId: string; }'.
routes-bad.ts(9,29): error TS2561: Object literal may only specify known properties, but 'orgID' does not exist in type '{ orgId: string; }'. Did you mean to write 'orgId'?
Text

Notice the cast inside buildPath. The implementation works on runtime strings with a regular expression, and TypeScript can’t prove that the regex and the type agree. They’re two parsers of the same syntax, and they can drift: if someone adds support for optional parameters (:id?) to the regex but not the type, call sites will be checked against the wrong rules. Keep both in one file, and test the runtime half with a few patterns.

Where template literal types stop

The biggest limit is that they only check strings TypeScript knows at compile time. In the route example, this call compiles without complaint:

declare const fromConfig: string;
buildPath(fromConfig, {});
TypeScript

When P is plain string, RouteParams<string> doesn’t match either pattern, resolves to {}, and any parameters are accepted. Values from configuration files, databases or user input are typed string, so the clever type doesn’t touch them. If those paths matter, validate them at runtime, for example by checking at startup that every configured route only uses known parameter names.

Other limits worth naming in an interview:

  • Recursion depth. Recursive string types like RouteParams have depth limits. Deeply nested or very long strings eventually produce “Type instantiation is excessively deep and possibly infinite” (TS2589). Route patterns are short, so it’s fine here; parsing whole SQL queries in the type system is not.
  • Readability. A colleague who has to debug a three-level conditional template type at 2 a.m. may wish you’d used a runtime check and a plain Record<string, string>. Use these types where a mistake is likely and expensive, like routes, event names or translation keys, not everywhere a string appears.
  • Error messages. When a pattern type rejects a value, the message shows the pattern, not the reason. '12' fails `${number}px` with a message that doesn’t mention the missing unit.

The intrinsic helpers Uppercase, Lowercase, Capitalize and Uncapitalize round out the toolkit. They’re implemented by the compiler, which is why you can’t write them yourself, and they combine with key remapping to produce names like onClick from 'click'.

Exercise: typed environment variables

Your service reads configuration from environment variables named APP_DB_URL, APP_DB_POOL_SIZE, APP_REDIS_URL and so on. A teammate proposes type EnvKey = `APP_${string}` and a getEnv(key: EnvKey): string helper. Is that useful? What would you change?

Answer

It’s slightly useful: it catches a key that’s missing the APP_ prefix, such as getEnv('DB_URL'). But `APP_${string}` accepts 'APP_DB_ULR', so the most likely mistake, a typo in the variable name, still compiles and returns undefined at runtime, which the string return type hides.

I’d enumerate the keys, since the set is small and known: const ENV_KEYS = ['APP_DB_URL', 'APP_DB_POOL_SIZE', 'APP_REDIS_URL'] as const and type EnvKey = (typeof ENV_KEYS)[number]. Typos then fail to compile. At startup, I’d loop over ENV_KEYS, read each from process.env, and fail fast with a list of missing variables, so the runtime check and the type come from the same array and can’t drift. The return type of getEnv should be string only after that validation has run; otherwise string | undefined is the honest type. If some keys hold numbers, like the pool size, a small schema that maps each key to a parser is better than casting at every call site. A pattern type like `APP_${string}` still has a place for prefix rules, for example a function that only accepts feature-flag variables named `APP_FLAG_${Uppercase<string>}`.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Infer for Type Extraction

How infer pulls element, return and parameter types out of other types, why readonly arrays don't match T[], and how to parse route strings.

~5 min readread →
read ✓TypeScript · hard

TypeScript Key Remapping in Mapped Types

Rename keys with as clauses, drop keys by mapping to never, map over a union of events, and spot the string & K and optional-method traps.

~6 min readread →
esc