DEV Community

Cover image for Your Kotlin Sealed Class Is Not a TypeScript Discriminated Union
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your Kotlin Sealed Class Is Not a TypeScript Discriminated Union


You add a fifth subclass to a sealed PaymentEvent in a Kotlin service. The IDE turns red across the package. Every when that branches on PaymentEvent lights up with a missing case. You walk through them, add the new branch, and ship. The compiler did the bookkeeping for you.

A week later you port that same domain to Node. You translate the sealed class to a union of object types tagged with a kind field, add the fifth member, search for kind === across the codebase, find six switches, fix them, and merge.

Three weeks later an alert fires. A handler silently dropped the new event because it if-checked four kind values and returned null for the rest. No compile error. No test failure. Just a quiet null that flowed through three more services before someone noticed the dashboard.

The gap between sealed classes and discriminated unions is wider than the syntax suggests. Kotlin closes the hierarchy at compile time. TypeScript closes it at the discriminant string, and only inside the call sites that already pattern-match on it. The compiler sees the same shape. It does not see the same closure.

For JVM devs moving to TypeScript, this is the habit that ships the most bugs. Null safety and generics variance fight you visibly. This one fails quietly.

What sealed actually means in Kotlin

A sealed class or sealed interface is a closed hierarchy. Subclasses must live in the same module and the same package. The compiler can enumerate every subtype because it owns the file boundary. That enumeration is what makes when exhaustive.

sealed interface PaymentEvent {
    val paymentId: String
}

data class Authorized(
    override val paymentId: String,
    val amountCents: Long,
) : PaymentEvent

data class Captured(
    override val paymentId: String,
    val amountCents: Long,
) : PaymentEvent

data class Refunded(
    override val paymentId: String,
    val amountCents: Long,
    val reason: String,
) : PaymentEvent

data class Failed(
    override val paymentId: String,
    val code: String,
) : PaymentEvent

fun summary(event: PaymentEvent): String = when (event) {
    is Authorized -> "auth ${event.paymentId} for ${event.amountCents}"
    is Captured   -> "captured ${event.paymentId}"
    is Refunded   -> "refunded ${event.paymentId}: ${event.reason}"
    is Failed     -> "failed ${event.paymentId} (${event.code})"
}
Enter fullscreen mode Exit fullscreen mode

That when returns a String. It has no else. The compiler accepts it because the four branches cover every direct subtype of PaymentEvent and the hierarchy is sealed. Add a fifth subclass tomorrow and this function stops compiling. Every other when (event) in the project stops compiling. The build is the safety net.

Kotlin 2.1 made this stricter, not looser. The improved exhaustiveness check now accepts sealed-class when without a redundant else even with generic upper bounds — see the Kotlin 2.1 release notes (and the community discussion of the limits covering nested sealed hierarchies). What JVM devs took for granted got cleaner. Add a subclass, the compiler tells you where it hurts.

One subtlety: the check is one level deep. If Refunded is itself sealed with three subclasses and the consumer matches is Refunded, the compiler is happy with the outer level. When you want depth, you seal the inner type too. The contract is simple: closure at the source, exhaustiveness at every consumer, both enforced by the compiler.

What a discriminated union actually means in TypeScript

A discriminated union is a union of object types that share a common literal-typed property. The compiler narrows the union by reading that property in a control-flow position. if (e.kind === "Authorized") narrows e to the Authorized member inside that branch. So does switch (e.kind).

type Authorized = {
  kind: "Authorized";
  paymentId: string;
  amountCents: number;
};

type Captured = {
  kind: "Captured";
  paymentId: string;
  amountCents: number;
};

type Refunded = {
  kind: "Refunded";
  paymentId: string;
  amountCents: number;
  reason: string;
};

type Failed = {
  kind: "Failed";
  paymentId: string;
  code: string;
};

type PaymentEvent = Authorized | Captured | Refunded | Failed;

function summary(event: PaymentEvent): string {
  switch (event.kind) {
    case "Authorized":
      return `auth ${event.paymentId} for ${event.amountCents}`;
    case "Captured":
      return `captured ${event.paymentId}`;
    case "Refunded":
      return `refunded ${event.paymentId}: ${event.reason}`;
    case "Failed":
      return `failed ${event.paymentId} (${event.code})`;
  }
}
Enter fullscreen mode Exit fullscreen mode

That summary looks like the Kotlin version. It is not the same thing.

A JVM dev reading this code will say the function is exhaustive. The compiler sees a union of four members and a switch covering all four. What the compiler is actually doing is narrower.

It is asking: does this function have a return path on every input? With the four-case switch and no fallthrough, yes — TypeScript is satisfied that summary always returns a string. Add a fifth member to PaymentEvent and the compiler flags this summary because a code path now returns undefined. That part works.

What does not work is the consumer without a return type, the one using if/else if, the one switching on a different discriminant, the one inside a callback whose return value is ignored, the one in a Redux reducer that returns state by default. None of those locations get flagged. The union grew. The call site shrank in coverage. Nobody noticed.

The discriminant is a string. Closure is a convention. The compiler trusts you.

The closure shifts to the call site

Kotlin and TypeScript are answering the same design question: how do we make sure every consumer handles every variant? They put the answer in different places.

Kotlin puts it at the type definition. The hierarchy is sealed. Closure is a property of the type. Every consumer inherits exhaustiveness for free.

TypeScript puts it at the consumer. The union is open in the sense that the compiler will happily let you write a function that handles three of four cases and returns string | undefined. Closure is a property of the call site. You earn exhaustiveness by writing the call site so that the compiler can prove it.

Two patterns earn it.

The first is the assertNever exhaustiveness check. Add a default case to every switch that consumes the union and assign the narrowed value to a never variable.

function assertNever(x: never): never {
  throw new Error(`unexpected variant: ${JSON.stringify(x)}`);
}

function summary(event: PaymentEvent): string {
  switch (event.kind) {
    case "Authorized":
      return `auth ${event.paymentId} for ${event.amountCents}`;
    case "Captured":
      return `captured ${event.paymentId}`;
    case "Refunded":
      return `refunded ${event.paymentId}: ${event.reason}`;
    case "Failed":
      return `failed ${event.paymentId} (${event.code})`;
    default:
      return assertNever(event);
  }
}
Enter fullscreen mode Exit fullscreen mode

If you handle all four of the union's variants, control flow narrows event to never in the default case, assertNever type-checks, and the function compiles. Add a fifth variant and the default case sees a value not assignable to never. The function stops compiling. The build is the safety net again, but only because you wrote the safety net by hand at every call site.

The second pattern uses ts-pattern's .exhaustive(). Same trick, as a library, with structural matching beyond a single discriminant tag. match builds a chain of patterns where each .with narrows the input by the supplied shape, and .exhaustive() is the assertion: if any value is unmatched, the call site fails to type-check with a NonExhaustiveError<...> message naming the missing variant.

For Kotliners, match is closer to the muscle memory of when. The expression evaluates to a value, and patterns match on shape rather than a single discriminant. The switch-and-default-and-throw boilerplate goes away — you write the arms, call .exhaustive(), and ship. The library has a small footprint (see the README for the current bundle figures), low enough that a careful reader of the bundle list will not flinch. The full match shape appears in the worked example below.

Where TypeScript's defaults nudge you

TypeScript 6 continues the long arc toward stricter defaults the language has been on since 4.x. None of those flags turn discriminated unions into sealed classes. The compiler still does not enforce exhaustiveness at every call site. strict: true and noImplicitReturns give you a floor: a function with a missing branch returning undefined is refused when the declared return type does not include undefined. That catches the most common shape of the bug — a function that forgot the new case. It does not catch the reducer that returns state by default, or the early return in a template helper.

ESLint covers some of the rest. The @typescript-eslint/switch-exhaustiveness-check rule flags a switch over a union of literals that misses a case — see the typescript-eslint docs. Worth turning on. Not the compiler. It will not surface as a red squiggle the way Kotlin's when does. A teammate who skips the lint step ships the bug.

Stacking the compiler defaults, assertNever or match().exhaustive(), and the lint rule is how you recover what Kotlin gives you for free: adding a variant breaks every consumer that needs to know.

What the discipline looks like at the call site

After a year of working alongside a Kotlin team, the habits that fill the gap are uniform.

Give every union a single canonical discriminant. kind is the convention worth standardizing on, because the more places that share it, the cheaper it is to grep, and type collides with TypeScript's type keyword in a way that reads worse. Put the union and its members in one file, exported together, so a reviewer sees the closure at a glance even though the language does not enforce it.

Wrap every consumer in match().exhaustive() or switch plus assertNever. Both work. Pick one per codebase, not both. The one you do not pick will start growing in a subdirectory you do not own.

Write a one-line test per consumer that drives a representative of every variant through it and asserts no throw of assertNever. With four variants and four consumers, that is sixteen tests. With ten variants and twenty consumers, replace the lot with one property test that drives every consumer with every variant from a generator. The cost stays flat.

Make adding a variant a deliberate action. A new PaymentEvent.Disputed lands in a PR that touches the union file and every consumer. The PR cannot merge if the build is red, and the build will be red because every consumer is wired through match().exhaustive(). This is the closest a TypeScript codebase gets to Kotlin's sealed-class compile gate. It is not free. It is the price of the same property in a language that puts closure at the call site.

A worked example: porting the closure habit

The full picture for a JVM dev porting a sealed hierarchy and wanting the Kotlin guarantee at the consumer.

Kotlin — closure at the type:

sealed interface ShipmentStatus {
    val orderId: String
}

data class Pending(override val orderId: String) : ShipmentStatus
data class Shipped(
    override val orderId: String,
    val trackingNumber: String,
) : ShipmentStatus
data class Delivered(
    override val orderId: String,
    val deliveredAtMillis: Long,
) : ShipmentStatus
data class Returned(
    override val orderId: String,
    val reason: String,
) : ShipmentStatus

fun customerEmailLine(s: ShipmentStatus): String = when (s) {
    is Pending   -> "We have your order ${s.orderId}."
    is Shipped   -> "Order ${s.orderId} ships with ${s.trackingNumber}."
    is Delivered -> "Order ${s.orderId} arrived."
    is Returned  -> "Order ${s.orderId} returned: ${s.reason}."
}
Enter fullscreen mode Exit fullscreen mode

TypeScript — closure earned at the consumer:

import { match } from "ts-pattern";

export type ShipmentStatus =
  | { kind: "Pending"; orderId: string }
  | { kind: "Shipped"; orderId: string; trackingNumber: string }
  | { kind: "Delivered"; orderId: string; deliveredAtMillis: number }
  | { kind: "Returned"; orderId: string; reason: string };

export function customerEmailLine(s: ShipmentStatus): string {
  return match(s)
    .with({ kind: "Pending" }, (x) =>
      `We have your order ${x.orderId}.`)
    .with({ kind: "Shipped" }, (x) =>
      `Order ${x.orderId} ships with ${x.trackingNumber}.`)
    .with({ kind: "Delivered" }, (x) =>
      `Order ${x.orderId} arrived.`)
    .with({ kind: "Returned" }, (x) =>
      `Order ${x.orderId} returned: ${x.reason}.`)
    .exhaustive();
}
Enter fullscreen mode Exit fullscreen mode

Add { kind: "Cancelled"; orderId: string } to ShipmentStatus. Both files refuse to compile until you handle it. The reason differs. Kotlin sees a closed hierarchy and an incomplete when. TypeScript sees a .exhaustive() whose final assertion can no longer prove the input is never.

Same outcome, different floor. Kotlin gives it to every consumer. TypeScript gives it to every consumer that wraps in match or assertNever.

Where this leaves you

You are not learning a worse pattern. You are learning a pattern with a different boundary. The cost is a library import, a default case in every switch, the habit applied across the team, and a CI rule on non-exhaustive switches (the @typescript-eslint/switch-exhaustiveness-check rule from earlier).

If you have ever told a teammate "the union is sealed, the compiler will catch it" — grep your TypeScript repo for switch (. Read every default. Count how many throw and how many return a fallback. The number that return a fallback is the number of bugs you have not seen yet.


If this was useful

The TypeScript Library is a 5-book collection. The book closest to this post is the bridge for JVM devs — Kotlin and Java to TypeScript — covering variance, null safety, sealed-to-union, and coroutines-to-async-await chapter by chapter.

The TypeScript Library — the 5-book collection

  • TypeScript Essentials — entry point on types, narrowing, modules, async, daily-driver tooling: Amazon
  • The TypeScript Type System — deep dive into generics, mapped/conditional types, infer, template literals, branded types: Amazon
  • Kotlin and Java to TypeScript — the bridge for JVM developers, including the sealed-to-union chapter this post sits next to: Amazon
  • PHP to TypeScript — the bridge for PHP 8+ developers: Amazon
  • TypeScript in Production — tooling, build, monorepos, library authoring across runtimes: Amazon

Hermes IDE is the editor where most of this code was sketched — built for developers who ship with Claude Code and other AI coding tools: hermes-ide.com.

Top comments (0)