Skip to content
Commitwise
Menu
Programming

Enums and sealed hierarchies

Model "exactly one of these" with enums, Swift associated values, Kotlin sealed classes, Java sealed interfaces and TypeScript discriminated unions, and let the compiler find every place you forgot to handle.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A search screen has three flags: isLoading, errorMessage and results. One day a bug report arrives with a screenshot: a spinner, an error message and “No results”, all at once. Three flags allow eight combinations, and the screen only makes sense in four of them.

Enums and sealed hierarchies let you say “the screen is in exactly one of these states”, and make the compiler check that every part of your code handles every state.

New to some of these? Open what you need, or skip ahead.

Basics:What are functions and parameters?

A function is a named piece of code you can run again and again. It takes parameters (inputs) and can return a result:

Swift:   func add(_ a: Int, _ b: Int) -> Int { a + b }
Kotlin:  fun add(a: Int, b: Int): Int = a + b
Java:    static int add(int a, int b) { return a + b; }
TS:      function add(a: number, b: number): number { return a + b; }

Calling it, add(2, 3), runs the code with a = 2 and b = 3 and gives back 5. A function that belongs to a class is usually called a method.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often uses plain objects described by an interface or type.
Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What is JSON?

JSON is the text format most APIs use to send data. Objects are written in braces with "key": value pairs, lists in square brackets:

{ "id": 7, "name": "Mug", "price": 9.5, "tags": ["kitchen", "gift"], "discount": null }

Values can be strings (in quotes), numbers, true/false, null, objects or lists. Turning JSON into your own types is called decoding (or parsing, or deserializing); the opposite is encoding.

Part Not started

A fixed set of choices

Think of a traffic light. It’s red, yellow or green, never two at once, and never “purple”. An enum (short for enumeration) is a type with exactly that kind of fixed list of possible values.

Before enums, people used strings or numbers ("red", 2), and nothing stopped a typo like "gren" from compiling. With an enum, the compiler knows every possible value, so it can check two things for you: that you only use real values, and that a switch handles all of them.

An enum is a closed set of named values; a sealed hierarchySealed hierarchyA type whose list of subtypes is fixed and known to the compiler, so a switch over it can be checked for completeness.Open in glossary → is a closed set of types, each of which can carry its own data. Together they’re the sum types (tagged unions) of these languages: Swift enums with associated values, Kotlin sealed interface/sealed class, Java 17+ sealed interface with records, matched with Java 21 pattern matchingPattern matchingChecking a value's shape (its type, case or contents) and pulling data out of it in one step.Open in glossary → for switch, and TypeScript’s discriminated unions, narrowed by a literal kind field.

The payoff is exhaustiveness: a switch/when without a catch-all branch is checked by the compiler, so adding a case turns every place that must handle it into a compile error. The Detailed version covers when a closed set is the wrong choice, library evolution (@unknown default), and decoding values your app has never seen.

enum Light {
    case red, yellow, green
}

func action(for light: Light) -> String {
    switch light {
    case .red:    return "Stop"
    case .yellow: return "Get ready"
    case .green:  return "Go"
    }
}
enum class Light { RED, YELLOW, GREEN }

fun action(light: Light): String = when (light) {
    Light.RED -> "Stop"
    Light.YELLOW -> "Get ready"
    Light.GREEN -> "Go"
}
enum Light { RED, YELLOW, GREEN }

static String action(Light light) {
    return switch (light) {
        case RED -> "Stop";
        case YELLOW -> "Get ready";
        case GREEN -> "Go";
    };
}
type Light = "red" | "yellow" | "green";

function action(light: Light): string {
  switch (light) {
    case "red":    return "Stop";
    case "yellow": return "Get ready";
    case "green":  return "Go";
  }
}

Notice there’s no default branch. Because the compiler knows all three values, it accepts the switch as complete. (In TypeScript, a union of string literals plays the role of the enum, and the check comes from the return type: if a value isn’t handled, the function could end without returning a string, and that’s an error.) If you later add case flashing, every switch without a flashing branch stops compiling, and the compiler gives you a to-do list of places to update.

Enums with values and behavior

Enums can also store a value per case and have their own properties and methods, which keeps related logic in one place:

enum CoffeeSize: String, CaseIterable {
    case small, medium, large

    var price: Double {
        switch self {
        case .small:  return 2.0
        case .medium: return 2.5
        case .large:  return 3.0
        }
    }
}

for size in CoffeeSize.allCases {
    print(size.rawValue, size.price)   // small 2.0, medium 2.5, large 3.0
}
enum class CoffeeSize(val price: Double) {
    SMALL(2.0), MEDIUM(2.5), LARGE(3.0);

    val label: String get() = name.lowercase()
}

for (size in CoffeeSize.entries) {
    println("${size.label} ${size.price}")
}
enum CoffeeSize {
    SMALL(2.0), MEDIUM(2.5), LARGE(3.0);

    final double price;

    CoffeeSize(double price) { this.price = price; }
}

for (CoffeeSize size : CoffeeSize.values()) {
    System.out.println(size.name().toLowerCase() + " " + size.price);
}
const coffeePrices = {
  small: 2.0,
  medium: 2.5,
  large: 3.0,
} as const;

type CoffeeSize = keyof typeof coffeePrices;   // "small" | "medium" | "large"

for (const [size, price] of Object.entries(coffeePrices)) {
  console.log(size, price);                   // small 2, medium 2.5, large 3
}

Every enum also knows its position or raw value, but be careful what you build on it:

Predict the output

What gets printed?

enum Size: Int {
    case small = 1, medium, large
}

print(Size.large.rawValue)
enum class Size { SMALL, MEDIUM, LARGE }

println(Size.LARGE.ordinal)
enum Size { SMALL, MEDIUM, LARGE }

System.out.println(Size.LARGE.ordinal());
enum Size { Small, Medium, Large }

console.log(Size.Large);
SwiftKotlinJavaTypeScript
Simple enumenum E { case a, b }enum class E { A, B }enum E { A, B }type E = "a" | "b" (or enum)
Exhaustive matchswitch (no default)when (expression or statement)switch expression, pattern switch (21+)switch in a function with a return type, or a never check
All valuesCaseIterable.allCasesentriesvalues()an as const array the type is derived from

Swift

Every switch must be exhaustive, statement or expression. Implicit Int raw values (case small = 1, medium, large) shift when a case is inserted: persist explicit raw values or names.

Kotlin

when over an enum or sealed subject must be exhaustive even as a statement (Kotlin 1.7+). Never persist ordinal: it shifts when cases are reordered.

Java

Only switch expressions and pattern switches are checked for exhaustiveness; a classic switch statement over an enum that misses a case compiles silently. Never persist ordinal().

TypeScript

Prefer string literal unions to enum: numeric enums are numbered like ordinals (and shift when members are inserted) and have a reverse mapping. A switch is checked only when the function must return a value, or when default assigns the value to never.

Check this part

  1. Why is an enum safer than strings like "red" and "green"?Why is a union like "red" | "yellow" | "green" safer than a plain string?

  2. You add case flashing to Light. What happens to a switch over it without a default?You add FLASHING to Light. What happens to a when over it without an else?You add FLASHING to Light. What happens to a switch expression over it without a default?You add "flashing" to the Light union. What happens to a switch in a function that must return a string?

  3. Which enum values are unsafe to store or send to a server?Which enum value is unsafe to store or send to a server?Which values are unsafe to store or send to a server?

  4. A switch used as a statement (not producing a value) misses a case of your enum or union. Does the compiler complain?

An enum is a fixed list of values; a switch without a default is checked by the compiler, so new values can't be forgotten.

Part Not started

Choices that carry data

Real states carry data: search results come with a list, an error comes with a message, and loading carries nothing. Each language lets every case have its own data. (TypeScript calls this a discriminated union: a union of object types that share a literal kind field, so checking kind tells the compiler which case you have.)

enum SearchState {
    case idle
    case loading
    case loaded([Product])
    case failed(message: String)
}

func title(for state: SearchState) -> String {
    switch state {
    case .idle:                      return "Search for a product"
    case .loading:                   return "Searching…"
    case .loaded(let products) where products.isEmpty:
                                     return "No results"
    case .loaded(let products):      return "\(products.count) results"
    case .failed(let message):       return "Error: \(message)"
    }
}
sealed interface SearchState {
    data object Idle : SearchState
    data object Loading : SearchState
    data class Loaded(val products: List<Product>) : SearchState
    data class Failed(val message: String) : SearchState
}

fun title(state: SearchState): String = when (state) {
    SearchState.Idle -> "Search for a product"
    SearchState.Loading -> "Searching…"
    is SearchState.Loaded ->
        if (state.products.isEmpty()) "No results" else "${state.products.size} results"
    is SearchState.Failed -> "Error: ${state.message}"   // smart cast: state is Failed here
}
sealed interface SearchState permits Idle, Loading, Loaded, Failed {}
record Idle() implements SearchState {}
record Loading() implements SearchState {}
record Loaded(List<Product> products) implements SearchState {}
record Failed(String message) implements SearchState {}

static String title(SearchState state) {
    return switch (state) {
        case Idle i -> "Search for a product";
        case Loading l -> "Searching…";
        case Loaded(var products) when products.isEmpty() -> "No results";
        case Loaded(var products) -> products.size() + " results";
        case Failed(var message) -> "Error: " + message;
    };
}
type SearchState =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "loaded"; products: Product[] }
  | { kind: "failed"; message: string };

function title(state: SearchState): string {
  switch (state.kind) {
    case "idle":    return "Search for a product";
    case "loading": return "Searching…";
    case "loaded":                                  // narrowed: state.products exists here
      return state.products.length === 0 ? "No results" : `${state.products.length} results`;
    case "failed":  return `Error: ${state.message}`;
  }
}

Compare this with three separate flags:

  • Impossible states can’t exist. A SearchState is loading or failed, never both. There’s no combination to get wrong.
  • Data lives where it makes sense. You can only read products in the loaded case and message in the failed case, so you can’t show an error message that’s left over from an old search.
  • The compiler checks the switch. Add a case like offline, and every screen that doesn’t handle it stops compiling.

Java note

The case Loaded(var products) syntax is a record pattern (Java 21): it checks the type and pulls the fields out in one step. when adds an extra condition. Before Java 21 you’d write if (state instanceof Loaded l) { … l.products() … }.

Practice: from flags to states

Let’s fix the search screen from the start of the tutorial, step by step.

  1. List what the user can actually see. Before the first search, a spinner, results (maybe empty), an error. That’s four states.
  2. Write down the data each one needs. Nothing, nothing, a list of products, a message.
  3. Make that the type. One case per state, with its data, like SearchState above.
  4. Replace the flags. Wherever the code set isLoading = true and cleared the error, it now sets state = .loading. One assignment, so it can’t half-happen.

Here’s the code you’re replacing. The bug from the screenshot is in it:

Spot the bugFlags that disagree

Tap the line with the bug.

struct SearchScreenState {
    var isLoading = false
    var errorMessage: String?
    var results: [Product] = []
}

func message(for s: SearchScreenState) -> String {
    if s.isLoading { return "Searching…" }
    if s.results.isEmpty { return "No results" }
    if let error = s.errorMessage { return "Error: \(error)" }
    return "\(s.results.count) results"
}
data class SearchScreenState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val results: List<Product> = emptyList(),
)

fun message(s: SearchScreenState): String {
    if (s.isLoading) return "Searching…"
    if (s.results.isEmpty()) return "No results"
    if (s.errorMessage != null) return "Error: " + s.errorMessage
    return s.results.size.toString() + " results"
}
record SearchScreenState(boolean isLoading, String errorMessage, List<Product> results) {}

static String message(SearchScreenState s) {
    if (s.isLoading()) return "Searching…";
    if (s.results().isEmpty()) return "No results";
    if (s.errorMessage() != null) return "Error: " + s.errorMessage();
    return s.results().size() + " results";
}
interface SearchScreenState {
  isLoading: boolean;
  errorMessage?: string;
  results: Product[];
}

function message(s: SearchScreenState): string {
  if (s.isLoading) return "Searching…";
  if (s.results.length === 0) return "No results";
  if (s.errorMessage) return "Error: " + s.errorMessage;
  return s.results.length + " results";
}

Exerciseeasy

Payment methods

A checkout supports these payment methods:

  • a card, with its brand (Visa, Mastercard) and last four digits,
  • PayPal, with the account email,
  • cash on delivery, with no extra data.

Model them as one type, then write describe(method) that returns the text for the order summary:

Visa ending in 4242
PayPal (ana@example.com)
Cash on delivery
›

Cards and PayPal carry different data, and cash carries none. That’s an enum with associated values in Swift, a sealed interface in Kotlin and Java, and a discriminated union in TypeScript.

›

Use a switch/when without a default/else (in TypeScript, in a function that returns a string), so adding gift cards later forces you to update describe.

SolutionTry it yourself first
enum PaymentMethod {
    case card(brand: String, last4: String)
    case payPal(email: String)
    case cashOnDelivery
}

func describe(_ method: PaymentMethod) -> String {
    switch method {
    case .card(let brand, let last4): return "\(brand) ending in \(last4)"
    case .payPal(let email):          return "PayPal (\(email))"
    case .cashOnDelivery:             return "Cash on delivery"
    }
}
sealed interface PaymentMethod {
    data class Card(val brand: String, val last4: String) : PaymentMethod
    data class PayPal(val email: String) : PaymentMethod
    data object CashOnDelivery : PaymentMethod
}

fun describe(method: PaymentMethod): String = when (method) {
    is PaymentMethod.Card -> "${method.brand} ending in ${method.last4}"
    is PaymentMethod.PayPal -> "PayPal (${method.email})"
    PaymentMethod.CashOnDelivery -> "Cash on delivery"
}
sealed interface PaymentMethod permits Card, PayPal, CashOnDelivery {}
record Card(String brand, String last4) implements PaymentMethod {}
record PayPal(String email) implements PaymentMethod {}
record CashOnDelivery() implements PaymentMethod {}

static String describe(PaymentMethod method) {
    return switch (method) {
        case Card(var brand, var last4) -> brand + " ending in " + last4;
        case PayPal(var email) -> "PayPal (" + email + ")";
        case CashOnDelivery c -> "Cash on delivery";
    };
}
type PaymentMethod =
  | { kind: "card"; brand: string; last4: string }
  | { kind: "payPal"; email: string }
  | { kind: "cashOnDelivery" };

function describe(method: PaymentMethod): string {
  switch (method.kind) {
    case "card":           return `${method.brand} ending in ${method.last4}`;
    case "payPal":         return `PayPal (${method.email})`;
    case "cashOnDelivery": return "Cash on delivery";
  }
}

Now add giftCard(code). In every language, describe stops compiling until you handle it. That’s the whole point: the compiler finds every place that needs to know about the new payment method, including the ones you forgot existed.

Closed or open?

A sealed type and an interface sit at opposite ends of the expression problem:

Sealed / enumOpen interface
Add a new caseEdit the type and every switch (the compiler finds them)Add a class; no existing code changes
Add a new operationWrite one new function with a switchAdd a method to the interface and every implementation
Who can add casesOnly the owning moduleAnyone
Good forData with a fixed shape: UI state, results, events, protocol messagesBehavior that varies and grows: plugins, strategies

Rules of thumb: model data that the program interprets as sealed types, and behavior that third parties extend as interfaces. The visitor pattern exists to get sealed-type benefits in languages without them; with pattern matching you rarely need it.

The fine print on “closed”:

  • Kotlin: subclasses of a sealed class or interface must be in the same module and package. when on a sealed subject must be exhaustive both as an expression and (since Kotlin 1.7) as a statement.
  • Java: permits subclasses must be in the same module (or package, in the unnamed module), and each must be final, sealed or non-sealed. A non-sealed subclass reopens that branch. Pattern switch over a sealed type is checked for exhaustiveness; at runtime, an unmatched value (from separately compiled code) throws MatchException.
  • TypeScript: a union is closed where it’s declared, but it’s structural, not nominal: any object with the right kind and fields is a member, and nothing is checked at runtime. Exhaustiveness comes from return types or a never check in default.
  • Swift: enums can’t be extended with new cases outside their declaration. Enums from resilient libraries (Apple’s SDKs) can grow cases in future OS versions, so the compiler asks for @unknown default, which still warns you at compile time when a known case is missing.

What does the log say?

Predict the output

An analytics event logs the current state as text. What gets printed?

enum State {
    case idle
    case loading
}

print(State.loading)
sealed interface State {
    object Idle : State
    object Loading : State
}

println(State.Loading)
sealed interface State permits Idle, Loading {}
record Idle() implements State {}
record Loading() implements State {}

System.out.println(new Loading());
type State = { kind: "idle" } | { kind: "loading" };

const state: State = { kind: "loading" };
console.log(String(state));
Exercisemedium

A checkout state machine

A checkout goes through these steps: cart → address → payment → confirming (waiting for the server) → done, or failed with a reason, from which the user can retry payment. The user can go back from address and payment, but not from confirming or done.

Design it so that:

  • each state carries exactly the data it has (an address only after the address step, an order id only when done),
  • an illegal transition, like “confirm” from the cart, can’t silently corrupt the state,
  • the UI and the tests can use the same logic.
›

Model the states as one sealed type and the user’s actions as another (events). The whole flow is then one pure function: (State, Event) -> State.

›

Switch over the pair of state and event. Allowed pairs produce the next state; everything else returns the current state unchanged (and can be logged). Pure functions are trivially testable.

SolutionTry it yourself first
enum Checkout: Equatable {
    case cart([Item])
    case address([Item])
    case payment([Item], ShippingAddress)
    case confirming([Item], ShippingAddress, PaymentMethod)
    case done(orderID: String)
    case failed([Item], ShippingAddress, reason: String)
}

enum Event {
    case proceed, back, retryPayment
    case addressEntered(ShippingAddress)
    case paymentChosen(PaymentMethod)
    case serverConfirmed(orderID: String)
    case serverRejected(reason: String)
}

func reduce(_ state: Checkout, _ event: Event) -> Checkout {
    switch (state, event) {
    case (.cart(let items), .proceed):                       return .address(items)
    case (.address(let items), .addressEntered(let a)):      return .payment(items, a)
    case (.address(let items), .back):                       return .cart(items)
    case (.payment(let items, let a), .paymentChosen(let m)): return .confirming(items, a, m)
    case (.payment(let items, _), .back):                    return .address(items)
    case (.confirming, .serverConfirmed(let id)):            return .done(orderID: id)
    case (.confirming(let items, let a, _), .serverRejected(let r)): return .failed(items, a, reason: r)
    case (.failed(let items, let a, _), .retryPayment):      return .payment(items, a)
    default:                                                 return state   // illegal: ignore and log
    }
}
sealed interface Checkout {
    data class Cart(val items: List<Item>) : Checkout
    data class Address(val items: List<Item>) : Checkout
    data class Payment(val items: List<Item>, val address: ShippingAddress) : Checkout
    data class Confirming(val items: List<Item>, val address: ShippingAddress, val payment: PaymentMethod) : Checkout
    data class Done(val orderId: String) : Checkout
    data class Failed(val items: List<Item>, val address: ShippingAddress, val reason: String) : Checkout
}

sealed interface Event {
    data object Proceed : Event
    data object Back : Event
    data class AddressEntered(val address: ShippingAddress) : Event
    data class PaymentChosen(val method: PaymentMethod) : Event
    data class ServerConfirmed(val orderId: String) : Event
    data class ServerRejected(val reason: String) : Event
    data object RetryPayment : Event
}

fun reduce(state: Checkout, event: Event): Checkout = when (state) {
    is Checkout.Cart -> when (event) {
        Event.Proceed -> Checkout.Address(state.items)
        else -> state
    }
    is Checkout.Address -> when (event) {
        is Event.AddressEntered -> Checkout.Payment(state.items, event.address)
        Event.Back -> Checkout.Cart(state.items)
        else -> state
    }
    is Checkout.Payment -> when (event) {
        is Event.PaymentChosen -> Checkout.Confirming(state.items, state.address, event.method)
        Event.Back -> Checkout.Address(state.items)
        else -> state
    }
    is Checkout.Confirming -> when (event) {
        is Event.ServerConfirmed -> Checkout.Done(event.orderId)
        is Event.ServerRejected -> Checkout.Failed(state.items, state.address, event.reason)
        else -> state                                   // no Back while the server decides
    }
    is Checkout.Failed -> when (event) {
        Event.RetryPayment -> Checkout.Payment(state.items, state.address)
        else -> state
    }
    is Checkout.Done -> state
}
sealed interface Checkout permits Cart, AddressStep, PaymentStep, Confirming, Done, Failed {}
record Cart(List<Item> items) implements Checkout {}
record AddressStep(List<Item> items) implements Checkout {}
record PaymentStep(List<Item> items, ShippingAddress address) implements Checkout {}
record Confirming(List<Item> items, ShippingAddress address, PaymentMethod payment) implements Checkout {}
record Done(String orderId) implements Checkout {}
record Failed(List<Item> items, ShippingAddress address, String reason) implements Checkout {}

static Checkout reduce(Checkout state, Event event) {
    return switch (state) {
        case Cart c when event instanceof Proceed -> new AddressStep(c.items());
        case AddressStep a when event instanceof AddressEntered e -> new PaymentStep(a.items(), e.address());
        case AddressStep a when event instanceof Back -> new Cart(a.items());
        case PaymentStep p when event instanceof PaymentChosen e -> new Confirming(p.items(), p.address(), e.method());
        case PaymentStep p when event instanceof Back -> new AddressStep(p.items());
        case Confirming c when event instanceof ServerConfirmed e -> new Done(e.orderId());
        case Confirming c when event instanceof ServerRejected e -> new Failed(c.items(), c.address(), e.reason());
        case Failed f when event instanceof RetryPayment -> new PaymentStep(f.items(), f.address());
        default -> state;                                     // illegal: ignore and log
    };
}
type Checkout =
  | { step: "cart"; items: Item[] }
  | { step: "address"; items: Item[] }
  | { step: "payment"; items: Item[]; address: ShippingAddress }
  | { step: "confirming"; items: Item[]; address: ShippingAddress; payment: PaymentMethod }
  | { step: "done"; orderId: string }
  | { step: "failed"; items: Item[]; address: ShippingAddress; reason: string };

type Event =
  | { type: "proceed" } | { type: "back" } | { type: "retryPayment" }
  | { type: "addressEntered"; address: ShippingAddress }
  | { type: "paymentChosen"; method: PaymentMethod }
  | { type: "serverConfirmed"; orderId: string }
  | { type: "serverRejected"; reason: string };

function reduce(state: Checkout, event: Event): Checkout {
  switch (state.step) {
    case "cart":
      return event.type === "proceed" ? { step: "address", items: state.items } : state;
    case "address":
      if (event.type === "addressEntered") return { step: "payment", items: state.items, address: event.address };
      if (event.type === "back") return { step: "cart", items: state.items };
      return state;
    case "payment":
      if (event.type === "paymentChosen") return { ...state, step: "confirming", payment: event.method };
      if (event.type === "back") return { step: "address", items: state.items };
      return state;
    case "confirming":
      if (event.type === "serverConfirmed") return { step: "done", orderId: event.orderId };
      if (event.type === "serverRejected") return { step: "failed", items: state.items, address: state.address, reason: event.reason };
      return state;                                  // no back while the server decides
    case "failed":
      return event.type === "retryPayment" ? { step: "payment", items: state.items, address: state.address } : state;
    case "done":
      return state;
  }
}

Design points:

  • Illegal states are unrepresentable: there’s no way to have a Done without an order id or a Payment without an address, so no screen has to handle “address is null at the payment step”.
  • Illegal transitions are explicit: the catch-all here is deliberate and only covers events, while the outer when over states stays exhaustive (the Kotlin version). Log ignored events; in debug builds, assert.
  • Pure reducer: tests are a table of (state, event) → expected state with no mocks. The ViewModel (or a TCA/MVI store) holds the current state, feeds events in, and runs side effects (the network call) when entering Confirming.
  • Process death and restore: because the state is plain data, it can be saved (Parcelable/Codable) and restored mid-checkout.
SwiftKotlinJavaTypeScript
Cases with dataassociated values case failed(String)sealed interface + data class / data objectsealed interface + record (17+)discriminated union { kind: "failed"; message: string }
Destructurecase .failed(let msg)is Failed -> state.message (smart cast)case Failed(var msg) (21+)case "failed": state.message (narrowed)
  • Sealed types for data the program interprets (UI state, results, events), open interfaces for behavior others extend: adding a case is a checked change, adding an operation is one new function.
  • State machines as a pure (State, Event) -> State function make illegal states unrepresentable and transitions testable.

Kotlin

A plain object case prints as State$Loading@1b6d3586; declare it data object. Map states to explicit event names for analytics.

Java

A record prints as Loading[]: readable, but it changes with a rename. Map states to explicit event names for analytics.

TypeScript

The union is purely structural: String(state) is [object Object], and nothing stops a { kind: "loaded" } without products from arriving at runtime if it came from outside the type system.

Check this part

  1. What is the advantage of one SearchState type over separate isLoading, error and results flags?

  2. How do you give each case its own data in Swift?How do you give each case its own data in Kotlin?How do you give each case its own data in Java?How do you give each case its own data in TypeScript?

  3. When is an open interface a better choice than a sealed type?

  4. You print the enum case State.loading. What do you get?You print a plain object Loading : State. What do you get?You print new Loading(), a record with no fields. What do you get?You log String(state) for { kind: "loading" }. What do you get?

Swift enums with associated values, sealed types and discriminated unions give each case its own data, so impossible states can't exist.

Part Not started

The default that hides a new case

Spot the bugRefunded orders get shipped

Tap the line with the bug.

enum PaymentStatus {
    case pending, paid, failed, refunded
}

func canShip(_ status: PaymentStatus) -> Bool {
    switch status {
    case .pending, .failed: return false
    default:                return true
    }
}
enum class PaymentStatus { PENDING, PAID, FAILED, REFUNDED }

fun canShip(status: PaymentStatus): Boolean = when (status) {
    PaymentStatus.PENDING, PaymentStatus.FAILED -> false
    else -> true
}
enum PaymentStatus { PENDING, PAID, FAILED, REFUNDED }

static boolean canShip(PaymentStatus status) {
    return switch (status) {
        case PENDING, FAILED -> false;
        default -> true;
    };
}
type PaymentStatus = "pending" | "paid" | "failed" | "refunded";

function canShip(status: PaymentStatus): boolean {
  switch (status) {
    case "pending":
    case "failed":
      return false;
    default:
      return true;
  }
}

Exhaustiveness as a refactoring tool

Exhaustive matching only helps if you keep it. Things that quietly turn it off:

  • default/else branches on your own types (the first Spot the bug).
  • Returning Boolean or nullable “flags” from a when instead of the case itself, so callers branch on if again.
  • is/instanceof chains instead of a switch/when expression: no exhaustiveness check at all.
  • Kotlin: when used as a statement on a non-sealed subject (a String, an Int), where nothing is checked.
  • TypeScript: a switch in a function that returns void, with no never check in default.

Make the dangerous direction a compile error, and treat the compiler’s list of errors after adding a case as your migration checklist.

The case the server added

Spot the bugOne refunded order breaks the whole list

Tap the line with the bug.

enum OrderStatus: String, Decodable {
    case pending, paid, shipped
}

struct Order: Decodable {
    let id: String
    let status: OrderStatus
}

let orders = try JSONDecoder().decode([Order].self, from: data)
@Serializable
enum class OrderStatus { pending, paid, shipped }

@Serializable
data class Order(val id: String, val status: OrderStatus)

val orders = Json.decodeFromString<List<Order>>(body)
enum OrderStatus { PENDING, PAID, SHIPPED }

record Order(String id, OrderStatus status) {}

List<Order> orders = mapper.readValue(body, new TypeReference<List<Order>>() {});
type OrderStatus = "pending" | "paid" | "shipped";

interface Order {
  id: string;
  status: OrderStatus;
}

const orders: Order[] = await response.json();

Deep dives

Swift

Deep dive@unknown default and enums from librariesInterview favorite

A switch over your own enum should be exhaustive without default, so adding a case breaks the build where it matters. Enums from Apple’s frameworks (and from other libraries built with library evolution) are non-frozen: new cases can appear in a future OS release.

For those, Swift asks for @unknown default: it handles cases that don’t exist yet at runtime, but still warns at compile time when the SDK you build against adds a case you don’t handle. You keep the exhaustiveness check without crashing on an older binary meeting a newer OS.

Kotlin

Deep diveSealed hierarchies across modulesInterview favorite

Subclasses of a sealed class or interface must be in the same module and package, which is what lets the compiler prove a when is exhaustive. Since Kotlin 1.7, a non-exhaustive when statement over a sealed type or enum is an error, not a warning.

The flip side for library authors: adding a subtype to a public sealed hierarchy breaks every client’s exhaustive when at compile time. That’s a feature inside one app, and a breaking API change in a published library, where an else branch or a non-sealed design may be the kinder choice.

Java

Deep diveThe enum singleton, EnumMap and EnumSetInterview favorite

Effective Java (item 3) calls a single-element enum the best way to write a singleton: the JVM guarantees one instance, and unlike a class with a private constructor it survives serialization and reflection attacks without extra code.

For collections keyed by an enum, EnumMap and EnumSet are the right tools: an EnumMap is an array indexed by ordinal(), and an EnumSet of up to 64 constants is a single long bit mask. Both are faster and smaller than HashMap and HashSet, and iterate in declaration order.

TypeScript

Deep diveWhy many teams avoid TypeScript enumsInterview favorite

Most TypeScript is erased when compiled: types simply disappear. enum is the best-known exception (namespaces and constructor parameter properties are others): it generates a runtime object (with a reverse mapping for numeric enums), so it doesn’t work with type-stripping tools like Node’s built-in TypeScript support or the erasableSyntaxOnly option, and const enum has its own problems with isolatedModules.

The common alternative is a union of string literals, optionally backed by an object for runtime values: const Status = { Active: "active", Paused: "paused" } as const; type Status = (typeof Status)[keyof typeof Status];. It’s exhaustively checkable with switch and never, readable in JSON, and fully erasable.

  • No default / else on your own types: it saves a line today and silently swallows tomorrow’s case.
  • Values from the network or other modules need an explicit unknown case, so a new server value degrades one row instead of failing the whole response.

Swift

SDK enums from resilient libraries can gain cases in future OS versions: handle them with @unknown default, which still warns at compile time when a known case is missing. Decode network enums with a custom init(from:) that maps unknown raw values to .unknown(raw).

Kotlin

Sealed subclasses must be in the same module and package. For network enums, add an unknown constant and decode with coerceInputValues = true (kotlinx.serialization) and a default.

Java

A pattern switch over a sealed type meeting a subclass compiled later throws MatchException at runtime. For network enums, @JsonEnumDefaultValue with READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE.

TypeScript

Nothing is checked at runtime: a new server value slips through as a value the type says is impossible, and your exhaustive switch falls off the end. Validate where the data comes in and map unknown values to "unknown"; in default, const unhandled: never = value makes missing cases a compile error.

Check this part

  1. canShip returns false for pending and failed, and true for everything else (default / else). Later, refunded is added. What happens?

  2. You add a new case. Which switches will the compiler flag?

  3. Your app decodes OrderStatus from JSON, and the server adds "refunded". What happens without special handling?Your app types status as "pending" | "paid" | "shipped", and the server adds "refunded". What happens without validation?

  4. Why does Swift ask for @unknown default when switching over some SDK enums?Where can subclasses of a Kotlin sealed interface be declared?A pattern switch over a sealed interface meets a subclass compiled later. What happens at runtime?What does const unhandled: never = state in a default branch do?

A default or else branch swallows cases added later. List every case, and give values from the network an explicit unknown case.

Key takeaways

  • An enum (or a union of string literals) is a fixed set of values; a sealed type, Swift enum with associated values or discriminated union is a fixed set of cases that can each carry data.
  • One state type instead of several flags makes impossible combinations impossible.
  • A switch/when without default/else is checked by the compiler, so new cases can’t be forgotten.
  • Never store or send an enum’s position (ordinal); use its name or an explicit code.
  • Sealed types for data the program interprets, open interfaces for behavior others extend: that’s the expression problem in practice.
  • Exhaustiveness is only as good as your discipline: no catch-alls on your own types, switch/when expressions over is/instanceof chains.
  • Values from the network or other modules need an unknown case; @unknown default and MatchException exist for the same reason.
  • State machines as a pure (State, Event) -> State function make illegal states unrepresentable and transitions testable.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.