TypeScript: Discriminated Unions & Polymorphism
Notes on picking between polymorphism and discriminated unions when writing typescript.
Discriminated Unions
https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html
// In TypeScript, discriminated unions are a union with a "discriminate".
// In the below example, "kind" is the discriminate.
export type Animal =
| { kind: 'sheep' }
| { kind: 'cat', breed: 'house' | 'wild' }
| { kind: 'human', greeting: string }
export function speak(animal: Animal) {
switch (animal.kind) {
case 'cat':
return animal.breed === 'domestic' ? 'meow' : 'ROAR';
case 'sheep':
return 'baa';
case 'human':
return animal.greeting;
default
assertNever(animal);
}
}- Simple use cases results in brief code
- Natural pattern is to
switchin consuming code, which can become verbose when there is a large number of functions that access many variants. - Callers of
speakhave full knowledge of the data in each animal. - Caller of
speakneed to import the the typeAnimal, and the function separately. - Logic for each variant lives alongside similar logic for other variants
- Naturally organizes functions near their consumers. Because of this:
speakis more likely it will get deleted when it is no longer used.- When code splitting, only required functions are be included 1
- Because
Animaldoesn't control its own data, we likely won't be organizing many tests aroundAnimal. Instead, many test will be against individual functions, or against callers of those functions.
Possible file layout

Polymorphism
https://www.typescriptlang.org/docs/handbook/interfaces.html
// Could also be a class
interface Animal {
speak(): string;
}
class Sheep implements Animal {
speak() {
return 'baa';
}
}
function createCat(breed: 'house' | 'wild'): Animal {
return {
breed,
speak() {
return this.breed === 'house' ? 'meow' : 'ROAR';
}
};
}
class Human implements Animal {
constructor(
private greeting: string;
) {};
speak() {
return this.greeting;
}
}- Scales cleanly to large numbers of variants
- Callers of speak only need to import
Animal, or nothing at all. - Information is hidden from callers of speak.
Animalrelated logic can be more easily be stubbed out when testing other code.- Logic for each variant lives alongside that variants definition
- Naturally organisms logic near type definitions. Because of this:
- Tests naturally become written around
Animal, and its functions. - Harder to delete/detect unused code
- When code splitting, more unused methods will be included in bundles 1
- Tests naturally become written around
- Allows for easy dependency injection
Possible file layout

Polymorphic Discriminated Unions
interface IAnimal {
kind: string;
speak(): string;
}
class Sheep implements Animal {
kind = 'sheep' as const;
speak() {
return 'baa';
}
}
interface Cat extends IAnimal {
kind: 'cat';
breed: 'house' | 'wild';
}
function createCat(breed: Cat['breed']): Cat {
return {
kind: 'cat';
breed,
speak() {
return this.breed === 'house' ? 'meow' : 'ROAR';
}
};
}
class Human implements Animal {
kind: 'human' as const;
constructor(
private greeting: string;
) {};
speak() {
return this.greeting;
}
}
export type Animal = Sheep | Cat | Human;- Well suited for cases were most logic should live near the type definitions, but we need to get access to concrete data outside of the definitions occasionally.
- Most complex. Easiest to make a mess of things with.
Notes
- Both patterns work fine with both mutable and functional styles. Don't sleep on using classes just because you're writing functionally.