Skip to content
exercises
TypeScript

Discriminated unions, two ways

string literals vs. classes.

IntermediateCourse

Model a type that can be one of several distinct shapes — and have TypeScript narrow it correctly based on a discriminant field. Two implementations: string literal unions and class hierarchies.

The Problem

You have an API response that’s either a success or an error. Naive typing:

type Result = {
data?: User;
error?: string;
ok: boolean;
};

TypeScript can’t tell you that data is defined when ok is true. You end up with data! non-null assertions or runtime checks.

Version 1: String Literal Discriminated Union

type Result<T> = { ok: true; data: T } | { ok: false; error: string };
function handle(result: Result<User>) {
if (result.ok) {
console.log(result.data.name); // TypeScript knows data exists
} else {
console.log(result.error); // TypeScript knows error exists
}
}

The ok field is the discriminant. TypeScript narrows the type inside each branch.

Version 2: Class Hierarchy

class Success<T> {
readonly ok = true as const;
constructor(readonly data: T) {}
}
class Failure {
readonly ok = false as const;
constructor(readonly error: string) {}
}
type Result<T> = Success<T> | Failure;
function handle(result: Result<User>) {
if (result instanceof Success) {
console.log(result.data.name);
} else {
console.log(result.error);
}
}

instanceof is the discriminant here. Classes add methods if you need them.

When to Use Each

Use string literal unions when the type is data-only and you want to keep it serializable. Use classes when you need methods on the variants or want instanceof narrowing.

Why It Matters

Discriminated unions eliminate a whole class of runtime bugs. When TypeScript can prove which variant you have, you stop writing defensive null checks and start writing code that assumes what it already knows.