Before you begin
Why this matters
Data often comes in several shapes: a sale by weight or by the piece, an order picked up or delivered. When each shape is its own alternative, the compiler stops you from reading a field that the value does not have, and it can point at every place that a new shape must be handled.
What this helps you reason about
- Tell a type that combines fields from a type that chooses between alternatives.
- Write a discriminated union, and read each alternative’s fields after checking its kind.
- Make the compiler report a kind that a switch does not handle.
A chocolate shop sells pralines by weight and bars by the piece. In TypeScript, an object type describes a record, and a union of object types describes a value that is one of several alternatives. This lesson follows how code tells the alternatives apart, and what happens when a new one is added.
Already know this? The short version
- A record type is a product: every value holds all of its fields. A sum type is a choice: every value is one alternative, with that alternative’s fields.
- In TypeScript, a discriminated union such as { kind: "weighed"; grams: number } | { kind: "counted"; count: number } is a sum type, and kind is its tag.
- Inside case "weighed" of switch (item.kind), the compiler lets you read only the weighed fields.
- A default that returns a value hides a kind added later; const missing: never = item; in the default turns that into a compile error.
- One object type with optional fields for every kind lets wrong values exist; prefer the union.
Words in this lesson (6)
- product type
- A type whose values each hold all of its fields at once, such as a record. A value with two yes-or-no fields can be one of 2 × 2 = 4 different values.
- sum type
- A type whose values are each exactly one of several alternatives, and each alternative has its own fields. A tag in the value says which alternative it is.
- union type
- A type made of several types joined with |, such as number | undefined: the value is a number, or it is undefined.
- literal type
- A type with exactly one value, such as "reading" or 5. TypeScript infers one for a const declared with a string or a number.
- discriminated union
- A union of object types that all have one property, often kind, with a different literal type in each, such as { kind: "weighed"; … } | { kind: "counted"; … }. Checking that property tells the compiler which alternative a value is.
- never
- The type that has no values. A value of type never can appear only in code that can never run, so assigning to a never variable checks that no case is left.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Records
What does a record hold?
Records, structs, and objects grouped named fields into one value. A record holds all of its fields at once. Every Bar below has a name, an answer to “has nuts?”, and an answer to “vegan?”; an object that leaves one out does not compile.
Count the possible values. With the two yes-or-no fields alone, a bar can be one of 2 × 2 = 4 kinds: nuts and vegan, nuts only, vegan only, or neither. Each field multiplies the number of possible values, so a record type is also called a product type.
type Bar = {
name: string;
nuts: boolean;
vegan: boolean;
};
const hazel: Bar = { name: "Hazel", nuts: true, vegan: false }; A record is a product type: every value holds all of its fields at once.
02 / Alternatives
How can one value be one of several alternatives?
Some data is a choice instead. The chocolate shop sells pralines by weight and bars by the piece. A sale by weight has a price per kilogram and a number of grams; a sale by the piece has a price for each piece and a count. Every sale is one of the two, never both. Prices are in cents, so 4000 is 40.00 per kilogram.
A sum type describes such a choice: each value is exactly one of several alternatives, and each alternative has its own fields. The possible values add up: every weighed sale, plus every counted sale.
In TypeScript, a union type joins types with |. Below, each alternative is an object type with a kind property, and each kind has its own literal type: "weighed" is a type whose only value is the text "weighed". A union of object types that all have such a property is a discriminated union. kind is the tag: it says which alternative a value is.
type Item =
| { kind: "weighed"; centsPerKg: number; grams: number }
| { kind: "counted"; centsEach: number; count: number };
const pralines: Item = { kind: "weighed", centsPerKg: 4000, grams: 250 };
const bars: Item = { kind: "counted", centsEach: 350, count: 3 }; A discriminated union is a sum type: each value is one alternative, and its kind says which.
Coming from Java: A TypeScript union needs no interface and no class: a type built from two object shapes is enough, and any object with one of those shapes fits.
Precisely: enums
A union of literal types with no fields, such as "dark" | "milk" | "white", is the simplest sum type: a list of names. TypeScript also has an enum declaration, which creates a JavaScript object while the program runs; a union of literal types is erased like every other type.
03 / Reading
How does code find out which alternative a value is?
To use a sum type, check the tag first. In switch (item.kind), each case names one alternative. Inside case "weighed", the compiler knows that item is a weighed sale, so item.grams is allowed. Inside case "counted", item.grams is a compile error, because a counted sale has no grams. This is called narrowing; a later topic, TypeScript narrowing and discriminated unions, covers it in depth.
The function below has no default and no return after the switch. The compiler accepts that, because the two cases cover both alternatives, so the end of the function can never be reached.
function price(item: Item): number {
switch (item.kind) {
case "weighed":
return (item.centsPerKg * item.grams) / 1000;
case "counted":
return item.centsEach * item.count;
}
} Check the kind first; inside each case, the compiler lets you read only that alternative’s fields.
Coming from JavaScript: In plain JavaScript, item.grams on a counted sale gives undefined, and the price becomes NaN. TypeScript refuses that read before the program runs.
Experiment
Price two kinds of sale with one switch
switch (item.kind) checks the tag, and each case runs for one alternative only. Inside case "counted", the compiler knows that item is the counted alternative, which has no grams property.
Why it matters Read an alternative’s fields inside the case for its kind; the compiler refuses a field that the alternative does not have.
Predict the two printed prices. Then change item.count at the end of the counted case to item.grams, and predict whether the program still runs.
type Item =
| { kind: "weighed"; centsPerKg: number; grams: number }
| { kind: "counted"; centsEach: number; count: number };
function price(item: Item): number {
switch (item.kind) {
case "weighed":
return (item.centsPerKg * item.grams) / 1000;
case "counted":
return item.centsEach * item.count;
}
}
const pralines: Item = { kind: "weighed", centsPerKg: 4000, grams: 250 };
const bars: Item = { kind: "counted", centsEach: 350, count: 3 };
console.log(price(pralines), price(bars));
What to look for
Step into price twice. The first time, item shows {kind: "weighed", centsPerKg: 4000, grams: 250}, and the stop after the switch line is the weighed return: the stepper does not stop on case lines. The second time, item shows {kind: "counted", centsEach: 350, count: 3}, and the stop after the switch line is the counted return. The debugger can stop on the same line twice; that is normal. The run prints 1000 1050. After the edit, nothing runs, and the compiler reports Line 10: Property 'grams' does not exist on type '{ kind: "counted"; centsEach: number; count: number; }'.
Expected output
1000 105004 / New alternatives
What happens when an alternative is added?
The shop starts selling gift boxes at a fixed price. That is a third alternative, with one field: its price in cents. Every piece of code that handles sales now needs a decision for gift boxes. In TypeScript, add { kind: "gift"; cents: number } to Item.
A switch with no default, in a function that must return a number, gets a check for free: with a case missing, the compiler reports Function lacks ending return statement and return type does not include 'undefined'. A default that returns 0 removes that check, and a gift box quietly costs 0.
To keep a default and the check, assign the value to a variable of type never, the type with no values. After the weighed and counted cases, item in the default can still be a gift. Once the gift has a case too, nothing is left, item has the type never, and the assignment compiles. Until then it does not, and the error names the alternative that is missing.
function price(item: Item): number {
switch (item.kind) {
case "weighed":
return (item.centsPerKg * item.grams) / 1000;
case "counted":
return item.centsEach * item.count;
case "gift":
return item.cents;
default:
const missing: never = item;
return missing;
}
} A default that returns a value hides a missing alternative; a never check in the default makes it a compile error.
Precisely: when the check works
The missing-return check depends on the return type and on strictNullChecks, which the stepper turns on. The never check works in any function, including one that returns nothing. Both are checks before the program runs: data from outside, such as parsed JSON with a kind the type does not list, still reaches the default while the program runs.
Experiment
Add a gift box that the switch does not price
default runs for every kind that has no case of its own, so the gift box gets 0, and the compiler sees nothing wrong. In the edit, item in the default can still be a gift, and a gift is not of type never, the type with no values.
Why it matters Put a never check in the default of a switch on a union, so that the compiler names every kind that has no case.
Predict the printed total for three bars and a gift box at 1500. Then replace return 0; in the default with two lines, const missing: never = item; and return missing;, and predict whether the program still compiles.
type Item =
| { kind: "weighed"; centsPerKg: number; grams: number }
| { kind: "counted"; centsEach: number; count: number }
| { kind: "gift"; cents: number };
function price(item: Item): number {
switch (item.kind) {
case "weighed":
return (item.centsPerKg * item.grams) / 1000;
case "counted":
return item.centsEach * item.count;
default:
return 0;
}
}
const basket: Item[] = [
{ kind: "counted", centsEach: 350, count: 3 },
{ kind: "gift", cents: 1500 },
];
let total = 0;
for (const item of basket) {
total = total + price(item);
}
console.log(total);
What to look for
Step through the loop. For the bars, the stop after the switch line is the counted return, and total becomes 1050. For the gift box, item shows {kind: "gift", cents: 1500}, and the stop after the switch line is return 0 in the default. The run prints 1050, not 2550. After the edit, nothing runs, and the compiler reports Line 13: Type '{ kind: "gift"; cents: number; }' is not assignable to type 'never'.
Expected output
105005 / Design
Why not one record with a field for every alternative?
Why not one record with a field for every alternative, and a kind field that says which ones count? Such a record can hold every sale. It can also hold a weighed sale with a count of 3, or a counted sale with 250 grams, because every value has every field. Nothing in the type says which fields belong to which kind, so every reader of the data must know the rule, and the compiler cannot check it.
Choosing a type in which a wrong value cannot be written at all is called making invalid states unrepresentable. A mistake then shows up as a compile error at the line that writes or reads the wrong field, not as a wrong price later. The discriminated union has none of those wrong values: a weighed sale has no count property, and kind can only be "weighed" or "counted".
The rule works the other way too. When a value always needs all of its parts, such as a sale that always has a customer name and an item, use a record. A record can hold a sum type as one of its fields, and an alternative can hold a record.
// Every field, for every kind
type LooseItem = {
kind: string;
centsPerKg?: number;
grams?: number;
centsEach?: number;
count?: number;
};
const mixed: LooseItem = { kind: "weighed", count: 3 };
// compiles: a weighed sale with a count and no grams Choose a type in which wrong values cannot be written: alternatives for a choice, a record for a combination.
06 / Try it
Can you explain a fresh example?
A locker delivery should cost 100. Decide what each line prints, which kind is charged wrongly, and what change would make the mistake visible.
type Delivery =
| { kind: "pickup" }
| { kind: "courier"; km: number }
| { kind: "locker"; door: number };
// A locker delivery should cost 100.
function fee(delivery: Delivery): number {
switch (delivery.kind) {
case "courier":
return 200 + 50 * delivery.km;
default:
return 0;
}
}
const orders: Delivery[] = [
{ kind: "pickup" },
{ kind: "courier", km: 4 },
{ kind: "locker", door: 12 },
];
for (const delivery of orders) {
console.log(fee(delivery));
}
What is printed, and which change makes the compiler point at the mistake?
Reveal the reasoning
0, then 400, then 0. Only "courier" has a case. The default gives 0 to the pickup, which is right, and to the locker, which should cost 100. The compiler accepts it, because the default covers every kind. Give "pickup" and "locker" cases of their own, and put const missing: never = delivery; in the default: then a fourth kind without a case would be a compile error.
Practice tickets
- SUM-101 · Slice orders at a pizza stand get the wrong price → Free
- SUM-102 · Stalls on the new capped rent pay the wrong amount → Sign in · Pro
- SUM-103 · The grain stall packs orders that the customer did not mean → Sign in · Pro