Discriminated unions, polymorphic components, template literals, and generics for tables and selects. The patterns I keep, the ones I dropped, and why type gymnastics is usually a smell.
The visual app-builder I worked on at the creator economy platform was a React and TypeScript designer creators used to drag together their own branded mobile apps. The state tree was hairy, but the part that nearly cost me a week wasn’t state. It was a Button component whose props had drifted into a 14-field interface where half the fields only made sense in three or four combinations. A junior on the squad shipped a “loading icon-only button with href” and the production layout broke for a chunk of creators on Wednesday afternoon. The types said it was fine. The types were wrong.
That bug is the reason I have strong opinions about TypeScript in React.
Here’s the deal. Most of the “advanced TypeScript for React” content I see online is a tour of type gymnastics. Conditional types nested four deep, mapped types over mapped types, recursive template literals that infer route params from a string. Cute. But the patterns that actually pay rent in a codebase millions of customers hit are boring. Discriminated unions. A polymorphic component or two. A generic table that you can read at a glance. That’s most of it.
Back to that Button. The old shape was something like { variant, icon?, label?, loading?, href?, onClick?, ... } and every consumer was guessing which combo was valid. The fix wasn’t more props. It was making invalid combos uncompilable.
import type { ReactNode, MouseEvent } from "react";
type ButtonBase = {
size?: "sm" | "md" | "lg";
loading?: boolean;
disabled?: boolean;
};
type ButtonLabel =
| { kind: "label"; label: string; icon?: ReactNode }
| { kind: "icon"; icon: ReactNode; ariaLabel: string };
type ButtonAction =
| { as: "button"; onClick: (e: MouseEvent<HTMLButtonElement>) => void; type?: "button" | "submit" }
| { as: "link"; href: string; target?: "_blank" | "_self"; rel?: string };
export type ButtonProps = ButtonBase & ButtonLabel & ButtonAction;
Now <Button as="link" onClick={...} /> doesn’t compile. Neither does <Button kind="icon" label="Save" /> without an ariaLabel. The discriminant (kind, as) is the contract. The IDE narrows inside the component cleanly, no as casts, no “I think this is defined” comments.
The trick that took me an embarrassingly long time to internalize: pick discriminants that match user intent, not implementation. kind: "icon" | "label" reads like a design-system spec. hasIcon: true | false; hasLabel: true | false does not. The first one tells you what the button is. The second describes the symptoms.
Every design system grows a Box or a Text that wants to render as different elements. <Text as="h2" />, <Text as="span" />. The “correct” polymorphic generic is famous for being unreadable. Here’s the version I actually ship.
import type { ElementType, ComponentPropsWithoutRef, ReactNode } from "react";
type TextOwnProps<E extends ElementType> = {
as?: E;
size?: "xs" | "sm" | "md" | "lg" | "xl";
weight?: "regular" | "medium" | "bold";
children: ReactNode;
};
export type TextProps<E extends ElementType> = TextOwnProps<E> &
Omit<ComponentPropsWithoutRef<E>, keyof TextOwnProps<E>>;
export function Text<E extends ElementType = "p">({
as,
size = "md",
weight = "regular",
children,
...rest
}: TextProps<E>) {
const Tag = (as ?? "p") as ElementType;
return (
<Tag data-size={size} data-weight={weight} {...rest}>
{children}
</Tag>
);
}
<Text as="a" href="/x">go</Text> gets href typed. <Text as="button" onClick={...}>x</Text> gets a click handler. <Text as="div" href="/x"> won’t compile. That’s the whole point. I’ll take this over a “perfect” generic that needs a Stack Overflow detour every time someone touches it.
A live-video creator startup I led engineering at had a Box written with the full forwardRef-with-polymorphic-ref pattern. Ten lines of types to do what a two-line component would. We replaced it. Nobody mourned.
Template literals are the part of TypeScript people brag about and then can’t read three months later. They earn their place in two spots, in my experience.
One, design tokens. The token name is a string, but only specific strings are real.
type Hue = "neutral" | "brand" | "danger" | "success" | "warning";
type Step = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
export type ColorToken = `${Hue}.${Step}` | "transparent" | "current";
export const color = (token: ColorToken) => `var(--c-${token.replace(".", "-")})`;
That gives autocomplete on color("brand.500") and rejects color("brnad.500") at the call site. Worth it.
Two, typed event names for an emitter. Same pattern. Beyond that, if I’m reaching for template literals I usually stop and ask whether I’m solving a real problem or showing off. Usually showing off.
Tables are where teams either lock in a clean API or pile up any. The shape I keep coming back to is small.
import type { ReactNode } from "react";
export type Column<Row> = {
id: string;
header: ReactNode;
cell: (row: Row) => ReactNode;
sortBy?: (row: Row) => string | number | Date;
width?: number;
};
export function DataTable<Row extends { id: string }>(props: {
rows: Row[];
columns: Column<Row>[];
onRowClick?: (row: Row) => void;
emptyState?: ReactNode;
}) {
if (props.rows.length === 0) return <>{props.emptyState ?? null}</>;
return (
<table>
<thead>
<tr>{props.columns.map((c) => <th key={c.id} style={{ width: c.width }}>{c.header}</th>)}</tr>
</thead>
<tbody>
{props.rows.map((r) => (
<tr key={r.id} onClick={() => props.onRowClick?.(r)}>
{props.columns.map((c) => <td key={c.id}>{c.cell(r)}</td>)}
</tr>
))}
</tbody>
</table>
);
}
The Row extends { id: string } constraint is doing most of the work. cell(row) is fully typed at the call site. No keyof magic, no field-path string lookups, no runtime “key not found” surprises. A Select<Option> follows the same shape and stays under 40 lines.
At the live-video creator platform, I’d introduced an atomic CSS system and a shared design system over a quarter. We were ~70% migrated. A Friday afternoon, five PRs went out in a batch to convert older Vue components. CI passed. Deploy ran. About 30 minutes later support pinged us, creator profile pages had no bio. The bio was in the DOM. A global CSS reset bundled with the design system was zeroing padding-top on <section> tags, the bio collapsed to zero height.
A teammate patched the bio container’s padding. Fine. Then three more reports landed for similarly-collapsed sections elsewhere. Rolled back the bundle. Re-shipped two days later with the reset scoped to a data-attribute boundary. Added Chromatic visual diffs against the most-visited routes.
The reason I’m telling this in a TypeScript article: the types didn’t catch it because the types weren’t about the thing that broke. TypeScript saves you from a class of bug, not all bugs. When I see a team layering more generics on top of components to “make sure designers don’t pass the wrong thing”, I usually find they’ve shipped a visual regression that no compiler check could have caught. Spend the type budget on user-facing contracts. Spend the visual-regression budget on the rest.
Couple of years ago, on a Tuesday morning on the creator-tools team, the Community feed read latency jumped from ~120 ms to over 8 seconds. We routed reads through three Aurora replicas behind a custom layer. The API response types said Post[]. They were Post[]. They were just stale by 14 minutes. Our types told the truth about shape and nothing about freshness.
I ended up adding a thin Fresh<T> wrapper for read paths that mattered, { data: T; servedAt: Date; maxAgeMs: number }, and a useFreshOrFallback hook in the React layer that surfaced a soft “data is X seconds old” badge when freshness slipped past the threshold. Not glamorous. But after that, when something looked wrong in the UI, the component itself told you whether to trust it. Types as a UX layer, not just a correctness check.
as?: E version is fine. Don’t ship a generic only one person on the team can read.cell(row) directly.any instead.Thanks for reading. If you’ve got thoughts, send them my way.