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:
tstype 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:
- Can a discriminated union express it in fewer lines? Prefer the union.
- Are you mapping over a set of keys (
keyof, key remapping)? Conditionals shine. - 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:
tstype 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.
tsxtype 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:
tstype ToastInput = | { kind: 'message'; text: string } | { kind: 'action'; text: string; actionLabel: string; onAction: () => void };
Forbid props together:
tstype ExclusiveIcon = { icon: ReactNode; iconName?: never } | { icon?: never; iconName: string };
Prefix event names or data attributes:
tstype 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.
tsconst 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.