Skip to content

Conditional Types and Template Literal Types

Use conditional and template literal types for real prop relationships and token unions, practical frontend TypeScript, not trivia.

5 min read
Conditional Types
Template Literal Types
TypeScript
React Props
Design Tokens

Generics track one shape through a component. Real UI APIs need relationships: if asChild is true, ref targets the child; if href is set, we are a link; if tone is danger, certain icons are required.

Conditional types and template literal types are how I encode those relationships without runtime surprises, and without turning every file into a type zoo.

Conditional types for prop relationships

The classic frontend case: mutually dependent props.

ts
// packages/ui/src/button-props.ts, illustrative type ButtonAsButton = { asChild?: false; href?: never; type?: 'button' | 'submit' | 'reset'; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; }; type ButtonAsLink = { asChild?: false; href: string; type?: never; onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void; }; type ButtonAsChild = { asChild: true; href?: never; children: React.ReactElement; }; export type ButtonProps = ButtonAsButton | ButtonAsLink | ButtonAsChild;

That is a discriminated union, often clearer than a naked conditional type. I reach for extends conditionals when deriving types from other types:

ts
type PropsFor<T extends 'button' | 'a'> = T extends 'a' ? { href: string; type?: never } : { href?: never; type?: 'button' | 'submit' }; type LinkProps = PropsFor<'a'>; // { href: string; type?: never }

Decision criteria:

  1. Can a discriminated union express it in fewer lines? Prefer the union.
  2. Are you mapping over a set of keys (keyof, key remapping)? Conditionals shine.
  3. Is the conditional only used once to impress reviewers? Inline the union.

infer for unwrapping library types

ts
// Extract element type from a React component props value field, illustrative type ValueOfSelect<P> = P extends { value: infer V } ? V : never; type ProjectSelectValue = ValueOfSelect<SelectProps<Project>>; // Project | null

Useful when wrapping third-party components and you want your wrapper's onChange to stay aligned without copying their generics by hand. Not useful when you own both sides, just export SelectProps<T> and reuse it.

Template literal types for tokens and classes

Design tokens and Tailwind-ish unions are where template literals justify the complexity:

ts
type Tone = 'primary' | 'danger' | 'muted'; type Scale = '50' | '100' | '200' | '500' | '700'; type ToneToken = `color-${Tone}-${Scale}`; // "color-primary-50" | "color-primary-100" | ... | "color-muted-700" type CssVarName = `--acme-${Tone}`;
ts
// Enforce class prefixes without maintaining a 200-line union by hand type Space = '1' | '2' | '3' | '4' | '6' | '8'; type PaddingClass = `p-${Space}` | `px-${Space}` | `py-${Space}`;

Pair with the tailwind-merge config: if TypeScript only allows bg-primary and merge knows bg-primary as a color group, the type system and runtime policy agree.

tsx
type IconButtonProps = { label: string; /** Restricted set, not arbitrary class strings */ pad?: PaddingClass; };

I do not try to type the entire Tailwind universe as template literals. That path is slow for the checker and hostile to cn() escape hatches. Type the public semantic tokens; leave raw utilities to string + review.

Practical patterns I keep in @acme/ui

Require a prop when a flag is set:

ts
type ToastInput = | { kind: 'message'; text: string } | { kind: 'action'; text: string; actionLabel: string; onAction: () => void };

Forbid props together:

ts
type ExclusiveIcon = { icon: ReactNode; iconName?: never } | { icon?: never; iconName: string };

Prefix event names or data attributes:

ts
type DataAttr = `data-acme-${string}`;

These are the same judgment calls as the toast architecture post and the forms form ownership, types make illegal states harder to represent.

What I refuse to encode

Not every runtime rule belongs in the type system:

  • Marketing copy length limits, lint or CMS validation
  • Color contrast, design tokens + a11y checks, not template literals of hex
  • Feature-flagged fields that change weekly, you will fight the checker

Types encode stable API laws. Product rules that churn weekly belong in runtime validation (forms post) or CMS constraints.

Align type unions with runtime sources

Generate unions from the same as const data CVA and tokens already use - do not hand-maintain a parallel string world that drifts.

ts
const tones = ['primary', 'danger', 'muted'] as const; type Tone = (typeof tones)[number]; type ToneClass = `bg-${Tone}`;

If CVA keys say primary and CSS variables say --color-brand, no conditional type repairs the design system, rename until the strings match. Runtime validation at network boundaries still belongs to Zod (or similar); these types police props you author, not JSON you parse.

Failure modes

Trivia types in app screens. If only one call site exists, a normal interface is enough. Conditional types belong in shared packages.

Template literal graphs that instantiate thousands of unions. Compile times melt; developers switch to string. Keep unions small and semantic.

Lying conditionals. A type that says href is required while runtime renders a <button>, worse than any, because it teaches false confidence.

Skipping the human layer. Types do not replace the a11y checks from the accessible-libraries and polymorphic posts. A perfectly typed div button is still a bad button.

Present-tense TS 6 / tsgo claims. Wrong for late Q3 2025 in this series. Stay on 5.x techniques that your actual tsc / IDE run today.

Continuity

CVA matrices, class merge policy, polymorphic hosts, composition, and generics set up prop relationships and token strings. Branded types and the TS 6 bridge continue the depth arc without pretending those releases have already landed here.

Takeaway

Conditional and template literal types pay off when they encode real UI laws, required props, forbidden pairs, semantic tokens, not when they turn a button into a type puzzle.