A developer typing at a keyboard in front of a monitor with a dark code editor and a red error underline, with a blurred comment thread on a laptop beside it
Zamir Khotov October 23, 2026 Frameworks & JavaScript

Discriminated Unions for React Props and When They Are the Wrong Tool

📧 Subscribe to JS Pulse

The JavaScript job market in one email: real numbers from 600+ job listings, 5 jobs worth applying to, and what actually changed. Join 9,000+ developers.

Eight months ago I posted a small React tip on LinkedIn. Two screenshots of the same notification component. On the left, a props type where everything was optional. On the right, the same component typed with discriminated unions, where each variant only accepted the props it actually needed. Pass onRetry to a success notification and TypeScript stops you. I wrote that this pattern makes wrong usage impossible, posted it, and moved on.

The post reached about 114,000 impressions and collected 68 comments. I expected the usual mix of "great tip" and a few people who disagree with everything. What I got instead was one of the best free code reviews I have ever received. Senior engineers took the example apart. Someone noticed within minutes that my "before" and "after" types did not even describe the same component. Someone else showed, with four lines of code, how the guarantee I was celebrating disappears the moment you spread a config object into the component. An engineering leader argued that if you need a union this complicated, you probably need more components, not smarter types. Others pointed at compiler performance in large design systems.

Some of the comments were simply right, and my post was wrong in the way most confident technical posts are wrong. It showed the pattern at its best and skipped every situation where it hurts.

So this article is the version I should have written in the first place. It covers what discriminated unions for React props actually do, where they genuinely help, and the four cases where the people in my comments were right and I was not. If you write components that other developers consume, this is worth fifteen minutes. And if you are preparing for a senior frontend interview, the trade offs in this article are closer to what you will be asked about than the pattern itself.

I run a job board for JavaScript developers, so I read a lot of job postings. TypeScript appears in most of the React roles I review now, and the senior ones regularly mention design systems or shared component libraries. Knowing the syntax of a union type is not what those roles pay for. Knowing when to use it, and when to stop, is.

What the Post Got Right About Optional Props

A props type made entirely of optional fields describes a component that accepts combinations nobody intended to support. This part of the original post holds up, and most of the comments agreed with it. Here is roughly the type I used as the "before" example.

type NotificationProps = {
  variant?: "success" | "error" | "warning";
  message: string;
  onRetry?: () => void;
  action?: { label?: string; onClick?: () => void };
  dismissAfter?: number;
  icon?: React.ReactNode;
  closable?: boolean;
};

Nothing in this type tells the person using the component that onRetry only makes sense for errors, or that dismissAfter was designed for warnings. The type accepts a success notification with a retry button and an auto dismiss timer. It accepts an action with no label. The component then has to decide at runtime what to do with all of that, and usually the answer is to silently ignore some of it.

One commenter described the problem better than I did. Optional props feel flexible when a component is new and has three call sites. As usage grows, every new consumer reads the props type, sees that everything is allowed, and passes whatever seems reasonable. The invalid combinations do not show up as errors. They show up as a notification that looks slightly wrong in one screen, and nobody remembers why.

Discriminated unions fix this by turning one loose object type into several precise ones, connected by a shared literal field. The fair version of the "after" type, which I will come back to in a moment, looks like this.

type BaseProps = {
  message: string;
  icon?: React.ReactNode;
  closable?: boolean;
};

type NotificationProps = BaseProps & (
  | { variant: "success"; action?: { label: string; onClick: () => void } }
  | { variant: "error"; onRetry: () => void }
  | { variant: "warning"; dismissAfter?: number }
);

Now variant is required and acts as the discriminant. When someone writes a success notification and adds onRetry as a JSX attribute, TypeScript reports that the property does not exist on that variant. When someone writes an error notification and forgets onRetry, TypeScript reports that it is missing. Inside the component, a switch on props.variant narrows the type, so each branch only sees the props that belong to it.

function Notification(props: NotificationProps) {
  switch (props.variant) {
    case "success":
      return <SuccessView message={props.message} action={props.action} />;
    case "error":
      return <ErrorView message={props.message} onRetry={props.onRetry} />;
    case "warning":
      return <WarningView message={props.message} dismissAfter={props.dismissAfter} />;
    default: {
      const unreachable: never = props;
      return unreachable;
    }
  }
}

The never check at the end is the part people skip and should not. If someone adds an "info" variant to the type later and forgets to handle it here, the build fails instead of the notification quietly rendering nothing. One small practical note. You cannot destructure onRetry at the top of the function, because it does not exist on every member of the union. You narrow first, then read.

For a component with a few clearly different states, this is a real improvement. The props type becomes the documentation, and the compiler enforces it on every call site. That was the claim of the original post, and for this case it is true.

My Example Was Unfair and the Comments Noticed Immediately

A "cleaner" refactor that silently drops features is not cleaner, it is just shorter. In the screenshot I posted, the "before" type had icon and closable, and the "after" type did not. The "before" type made variant optional, and the "after" type quietly made it required. One commenter pointed this out within minutes and he was completely right. My "after" looked elegant partly because it described a smaller component.

This matters beyond one LinkedIn post. The same commenter made a broader point that I have thought about since. Many "look how much better this is" examples online work the same way. Required code disappears, a feature stops working, and the result is presented as simplification. Experienced developers learn to distrust these comparisons, and they should.

The fair version is the one above, with shared props pulled into a base type and intersected with the union. It is a little longer than my screenshot. It is also honest, because it shows the real cost of the pattern. You now have a base type, a union, an intersection, and a required discriminant that every call site must pass. For three variants that is fine. For eight variants with partially overlapping props, the type starts to need its own documentation.

If you take one practical habit from this section, take this one. Whenever you refactor a type to make it "safer", diff the set of things a caller can express before and after. If the new type cannot express something the old one could, decide explicitly whether that was a bug or a feature. Do not let it happen by accident, and definitely do not present it as an improvement in a code review without saying so.

In code review this is also one of the easiest ways to show seniority. Anyone can propose a union. Fewer people notice that the proposal removed closable from forty call sites.

Types Do Not Make Invalid States Impossible

Discriminated unions only protect the props that TypeScript can see, and spreading objects into a component hides props from that check. This was the most useful correction in the whole thread. My post said invalid states become impossible. A commenter replied with a short example that broke the claim.

const defaultConfig: Partial<NotificationProps> = {
  dismissAfter: 5000,
};

return (
  <Notification
    {...defaultConfig}
    variant="success"
    message="Saved"
  />
);

This compiles. TypeScript only reports unknown properties when you write them directly as attributes. Props that arrive through a spread are not checked that way, so dismissAfter rides along into a success notification. If your implementation reads dismissAfter without narrowing first (for example, a shared timer hook at the top of the component), the success message will disappear after five seconds, which is exactly the kind of bug the union was supposed to prevent.

The commenter's conclusion is the one I now agree with. The props type has to stay in sync with what the implementation actually does, and it should only expose props that have an effect. A union does not give you safety on its own. It gives you a contract. The implementation has to honor it, and the callers can still step outside it with spreads, casts, any, or a Partial that is wider than they think.

So the honest wording is narrower than my original post. Discriminated unions make invalid states much harder to express accidentally through the declared API of a component. That is a real benefit. It is not the same as impossible, and a team that believes "impossible" stops looking for the places where props enter the component sideways.

In practice this means three things. Keep config objects typed per variant instead of as Partial of the whole union. Narrow on the discriminant before reading variant specific props, everywhere, including inside hooks. And treat every spread into a typed component as a place where the compiler has partially stopped watching.

I write about patterns like this, and about what JavaScript companies actually ask for in their job posts, in JS Pulse, my newsletter for JavaScript developers. You can subscribe here.

Sometimes the Answer Is More Components, Not Smarter Types

When variants behave differently rather than just look different, separate components usually beat one component with a complex union. An engineering leader made this argument in the comments, and it is the one I underweighted the most. His point was simple. If the valid props depend this much on variant, the variants are probably different components that share a base.

function NotificationBase(props: {
  tone: "success" | "error" | "warning";
  message: string;
  children?: React.ReactNode;
}) {
  /* layout, icon, colors, close button */
}

function ErrorNotification(props: { message: string; onRetry: () => void }) {
  return (
    <NotificationBase tone="error" message={props.message}>
      <button onClick={props.onRetry}>Retry</button>
    </NotificationBase>
  );
}

Each public component now has a flat, boring props type. There is no discriminant to remember, no narrowing inside the implementation, and no question about which props apply. The caller chooses the component, and the choice itself carries the meaning that variant used to carry.

I still think the union is the better choice in one specific situation, and I said so in my reply at the time. When the variants differ mostly in presentation and share most of their props, a single component with a union keeps the API cohesive without multiplying exports. A button with a few visual variants is a good example. A notification where one variant retries a network call and another auto dismisses on a timer is closer to the other side of the line.

The useful test is behavior. If adding a variant means adding a new prop and a new branch of logic, you are probably growing a family of components inside one. If adding a variant means adding a color and an icon, the union is fine. Another commenter added a version of this I liked. Once the complexity moves from describing states to orchestrating behavior, a different architecture often makes more sense than a bigger type.

Complex Types Have a Cost in Large Codebases

Discriminated unions are cheap on their own, but unions combined with generics and conditional types can slow the compiler and confuse the people using your components. A few commenters who work on component libraries raised this, and one described what his team does about it. They keep the public props type deliberately simple and handle the remaining edge cases with runtime guards inside the component.

The TypeScript team's own performance guidance points in the same direction. It recommends preferring base types and interfaces over very large unions, because checking a value against a union means comparing it with the members of that union . A union of three notification variants will never be the reason your editor feels slow. A polymorphic component whose props are computed from generics, mapped over a union of intrinsic elements, and filtered with conditional types can be.

The second cost is human. When a consumer passes a wrong prop to a simple union, the error message is readable and points at the property that does not belong. When the type is built from several layers of generics, the error can turn into a wall of text that names internal helper types the consumer has never seen. At that point the type has stopped being documentation. It has become an obstacle that people work around with as any, which is the opposite of what you wanted.

The rule I would use is the one I wrote in reply to that thread. The public API is where clarity matters most. Internally you can be as clever or as pragmatic as you need, but the surface other developers touch should describe intent in a way a tired person can read at the end of a long day.

Strong Types Are a Tool, Not a Religion

The most common advice about TypeScript in React, "make invalid states unrepresentable", is good advice that becomes bad advice when it is followed without judgment. I repeated it in my post with full confidence. The comments taught me where it breaks.

The phrase is useful because it pushes developers away from the worst habit in component design, which is making everything optional and sorting it out at runtime. That habit is real and it is everywhere. But the phrase also carries an implied promise that more precise types are always better. They are not. Past a certain point, every additional constraint in the type system is a cost paid by everyone who reads, extends, or debugs the component, and the benefit is protection against mistakes that nobody was going to make.

One commenter put the opposite extreme bluntly. He called the whole pattern overcomplexity and argued for a generic action callback instead of variant specific ones. I do not agree with him for shared components, but I understand why he wrote it. He has probably seen a codebase where every component had a type more complicated than its implementation, and every change required a negotiation with the compiler.

The position I hold now sits between my original post and his comment. Discriminated unions are the right default for component APIs with a small number of genuinely distinct states. They are the wrong tool when the variants behave differently enough to be separate components, when the type grows faster than the feature, or when the team starts trusting the type more than the tests.

This is also, I think, what separates senior developers in practice. Not knowing the pattern, which takes an afternoon to learn, but knowing the three situations where you should not use it, and being able to explain that in a code review without turning it into a fight.

What I Would Post Today

The best technical content I have published came from being publicly wrong in front of developers who knew more than I did about specific cases. I did not plan that. I posted a tip, and the comments turned it into something more accurate than anything I would have written alone.

If I posted the same screenshots today, I would change three things. I would make the "before" and "after" types describe the same component, shared props included. I would replace "impossible" with "much harder to do by accident through the component API". And I would add one line saying that if your variants have different behavior, you probably want separate components built on a shared base.

That version would probably get fewer reactions. Nuance travels worse than certainty on social media. But it is the version that would still be true after a hundred developers tried it in their own codebases, which is the only kind of advice worth giving to people whose job depends on getting it right.

If you are looking for React and TypeScript roles where this kind of judgment is the actual job, I collect current JavaScript and TypeScript openings with real salary ranges and honest information about where companies can hire at jsgurujobs.com. For a deeper set of patterns, my earlier guide to TypeScript advanced patterns for React covers generics and polymorphic components in more detail. If code review is where these debates happen in your team, this piece on giving and receiving code review feedback is a good companion. And if you are trying to move up a level, what actually separates mid from senior developers makes the same argument about judgment from the career side.

The type is a promise to the next developer, so only promise what your component actually keeps.

FAQ

When should I use discriminated unions for React props? Use them when a component has a small number of clearly different states that each need different props. They work best when variants differ in presentation and share most props. If variants behave differently, separate components built on a shared base are usually cleaner.

Do discriminated unions make invalid props impossible in React? No, they make invalid props much harder to pass by accident through the declared component API. Props spread into the component, values typed as Partial of the whole union, casts and any can still slip invalid combinations through. The implementation also has to narrow on the discriminant before reading variant specific props.

Do large union types slow down TypeScript? They can, especially when unions are combined with generics and conditional types in large component libraries. The TypeScript team's performance guidance recommends base types over very large unions. A union of three or four variants is not a performance problem.

📧 Subscribe to JS Pulse

The JavaScript job market in one email: real numbers from 600+ job listings, 5 jobs worth applying to, and what actually changed. Join 9,000+ developers.

Related articles

frameworks 3 months ago

TypeScript Just Got Rewritten in Go. Here Is What That Means for Your Next Job Interview

Microsoft just shipped the TypeScript 7.0 RC. Compiler rewritten in Go. 10x faster builds. But the part nobody's talking about is what breaks in migration and what it means for your next technical interview.

Zamir Khotov Read more
Node.js 26 release notes highlighting the removal of the experimental-transform-types flag and its impact on TypeScript enum support.
frameworks 5 months ago

Node.js 26 Quietly Killed Your TypeScript Enums

A developer messaged me on Tuesday morning asking why his deploy was broken. He had just upgraded a small internal tool to Node.js 26 the same day the release dropped. Local tests passed. CI passed. The container built. Then the service refused to start in production with an error nobody on his team had ever seen before.

Zamir Khotov Read more
JavaScript job board built with Laravel and Blade templates instead of React showing server-rendered architecture choice for content products in 2026
frameworks 5 months ago

I Built a JavaScript Job Board Without React

I run jsgurujobs.com. The site has 430+ JavaScript job listings, 115+ blog posts, a newsletter with 4,200 subscribers, and a Telegram channel.

Zamir Khotov Read more