skip to content

In TypeScript, given `type Shade = 'light' | 'dark'` and `type Color = 'red' | 'blue'`, what type is `` `${Shade}-${Color}` `` and what rule produces it?

level: middleimportance: should knowfreq 55%

answer

  1. each union placeholder distributes
  2. sizes multiply, they do not pair up
  3. cross-product, not a zip
  4. primitive placeholder stays a pattern
  5. the product has a ceiling

basics

~10 s

It is the four-member union "light-red" | "light-blue" | "dark-red" | "dark-blue". A union placed in a placeholder distributes, so the result is the cross-product of every placeholder's members.

solid answer

~50 s

You get a union of four string literal types: `"light-red" | "light-blue" | "dark-red" | "dark-blue"`. The rule is that each placeholder holding a union distributes over the template, and the results multiply — with placeholders of size m and n you get m × n literal members, and a third placeholder multiplies again. That is what makes template literal types useful for generating class-name, event-name and CSS-property vocabularies from small source unions instead of typing them out by hand, and it is why autocomplete offers every valid combination. The multiplication is also the trap: unions grow fast, and past an internal limit of around 100,000 members the compiler refuses with an error saying the union type is too complex to represent. If a placeholder is plain `string` rather than a union, no expansion happens at all — you keep a pattern type instead of an enumerated union.

code

typescript · 13 lines
typescript
type Shade = "light" | "dark";
type Color = "red" | "blue";

// Cross-product: 2 x 2 = 4 literal members
type Token = `${Shade}-${Color}`;

const ok: Token = "dark-blue";
// @ts-expect-error - "dark-green" is not in the product
const bad: Token = "dark-green";

// A primitive placeholder does not enumerate; it stays a pattern
type Open = `${Shade}-${string}`;
const anything: Open = "light-whatever-you-like";

go deeper

for a junior

Be able to expand a small example by hand: two options times two options gives four literal members, joined by the fixed text. Say the result is a union of string literal types.

for a middle

State the distribution rule precisely and explain why counts multiply rather than pair. Contrast a union placeholder, which enumerates, with a ${string} placeholder, which leaves a pattern behind.

for a senior

Show judgment about size: estimate the product before writing it, recognise the too-complex-to-represent failure, and describe how you would restructure — fewer axes, an open ${string} tail, or constraining at the use site.

for a principal

Own the decision of whether a vocabulary belongs in the type layer at all. Weigh the autocomplete and refactor safety a derived union buys against compile-time cost and error readability for everyone touching the codebase.

## The distribution rule When a placeholder in a template literal type holds a union, the compiler builds one result per member and unions the results. With more than one such placeholder it does this for every combination: ```ts type Shade = "light" | "dark"; type Color = "red" | "blue"; type Token = `${Shade}-${Color}`; // "light-red" | "light-blue" | "dark-red" | "dark-blue" ``` Four members from two-by-two. Add a third placeholder and the count multiplies again: two shades × two colors × three weights is twelve literal members. This is a cross-product, not a zip — there is no pairing by position. ## Why this is the feature people actually use Before template literal types, a vocabulary of structured strings had to be spelled out: ```ts type Token = "light-red" | "light-blue" | "dark-red" | "dark-blue"; ``` That list has to be maintained by hand and drifts the moment someone adds a color. Deriving it keeps one source of truth per axis: ```ts type Color = "red" | "blue" | "green"; type Token = `${Shade}-${Color}`; // six members, updated automatically ``` The derived union is a real union of literal types, so everything that works on literal unions works here: assignability checks reject `"light-green"` if `green` is not in `Color`, editors autocomplete the full list, and exhaustive `switch` handling behaves normally. ## Prefixes and suffixes count too Literal text around the placeholders is fixed, so you can generate namespaced vocabularies: ```ts type Entity = "user" | "order"; type Action = "created" | "deleted"; type EventName = `app:${Entity}.${Action}`; // "app:user.created" | "app:user.deleted" // | "app:order.created" | "app:order.deleted" ``` A single placeholder with a union and no other variation simply maps over the members: ```ts type Handler = `on${"Click" | "Focus"}`; // "onClick" | "onFocus" ``` ## Union placeholder versus primitive placeholder The distribution only happens when there is a finite set to distribute over. A primitive placeholder produces a *pattern* type instead, which stays unexpanded: ```ts type A = `${Shade}-${Color}`; // enumerated union of 4 literals type B = `${Shade}-${string}`; // pattern: `light-${string}` | `dark-${string}` type C = `${string}-${string}`; // a single pattern type, nothing enumerated ``` That difference matters in practice. An enumerated union gives autocomplete and rejects typos; a pattern type only checks the fixed segments. Choosing between them is a modelling decision: enumerate when the vocabulary is genuinely closed, use a pattern when the tail is open-ended. ## The explosion, and why it bites Because the sizes multiply, the union gets large quickly. Four placeholders of twenty members each is 160,000 combinations. The compiler builds these unions eagerly and refuses past an internal ceiling on the order of 100,000 constituents, reporting that the expression produces a union type that is too complex to represent. Even well under the ceiling, a union of tens of thousands of literals is slow to check and produces error messages that print an unreadable wall of members. Practical mitigations, in rough order of preference: - Keep the source unions small and the number of placeholders low; two or three axes is the comfortable range. - Replace an axis that is effectively open with a primitive placeholder — `` `${Shade}-${string}` `` costs nothing to build. - Constrain at the use site with a generic parameter instead of materialising the whole product up front, so the compiler only checks the combination actually passed. ## What it is not The expansion is a compile-time construction. No array of strings is generated, nothing is iterable, and you cannot loop over the members at runtime — the union is erased along with the rest of the type layer. If you need the actual list of tokens at runtime, declare the values (for instance with an `as const` array) and derive the type from them rather than the other way round.

  • What changes if one of the placeholders is `string` instead of a union?
    No enumeration happens for that placeholder. `` `${Shade}-${string}` `` yields two pattern types, `` `light-${string}` `` and `` `dark-${string}` ``, rather than a list of concrete literals. You lose autocomplete and typo rejection in the open segment, but you also pay nothing in union size — which is often the right trade for an open-ended tail.
  • Why can a template literal type over several large unions fail to compile at all?
    The member counts multiply, so a few placeholders over sizeable unions reach hundreds of thousands of combinations. The compiler materialises the union eagerly and refuses past an internal limit around 100,000 constituents, reporting that the union type is too complex to represent. Keep the axes few and small, or leave the open axis as `string`.
  • Can you iterate the generated members at runtime to build a list of valid tokens?
    No — the union exists only during checking and is erased on emit. If you need the values, declare them as data first, for example an array with `as const`, and derive the type from that array. Going the other way round is impossible because there is nothing left to read.

saying these in an interview costs you the question

  • Says the placeholders pair up positionally instead of multiplying
  • Thinks the union can be iterated at runtime
  • Assumes a string placeholder also enumerates values
  • Believes the compiler expands lazily so size never matters
  • Claims the result is one string type rather than a union

context