An order model: putting it together
The house is built: it has module rooms, rules on the wall and manuals for the devices. What is left is to furnish it, and everything from the course comes in handy for that.
Let's build an order model for an online shop. An order lives by these rules:
- it is new, paid or shipped; a paid order has a total, a shipped one has the parcel's tracking number;
- the order number never changes after the order is created;
- the address and the comment can be corrected, but the number cannot;
- the total is typed into a form, the input may turn out wrong, and the code must check it;
- each state has its own status text, and a new state cannot be forgotten.
We will write every rule down in types. Then a violation is noticed by tsc during the check, not by a user in the running program.
Order states
The states are a discriminated union with the discriminant kind, and the number is a readonly field:
type OrderState =
| { kind: "new" }
| { kind: "paid"; total: number }
| { kind: "shipped"; track: string };
interface Order {
readonly id: number;
address: string;
state: OrderState;
}
const order: Order = { id: 7, address: "Elm 5", state: { kind: "new" } };
order.state = { kind: "paid", total: 500 };
console.log(order.id, order.state.kind); // 7 paidLet's go through the example line by line:
OrderStatehas three variants, and each has its own literal inkindand its own fields.- In
Orderthe number is markedreadonly, and the state lives in thestatefield. order.state = { kind: "paid", total: 500 }replaces the state as a whole: the order is now paid.- The program prints
7 paid.
Now tsc checks the rules. The line order.id = 8 fails to compile with error TS2540: the number is read-only. And a shipped order without a tracking number does not fit OrderState:
error TS2322: Type '{ kind: "shipped"; }' is not assignable to type 'OrderState'.
Property 'track' is missing in type '{ kind: "shipped"; }' but required in type '{ kind: "shipped"; track: string; }'.Status text
The status text is built by a switch over the discriminant. Each case narrows the state to its variant, and default holds the exhaustiveness check with never. Press "Next step" and watch which type s has on the highlighted line.
1type OrderState =2 | { kind: "new" }3 | { kind: "paid"; total: number }4 | { kind: "shipped"; track: string };5 6function statusText(s: OrderState): string {editor hovers: OrderState7 switch (s.kind) {8 case "new":9 return "waiting for payment";10 case "paid":11 return `paid: ${s.total}`;12 case "shipped":13 return `on the way: ${s.track}`;14 default:15 const rest: never = s;16 return rest;17 }18}
On the way in, s may be any of the three order states.
Add a fourth variant { kind: "cancelled" } to OrderState and change nothing in statusText. A cancelled order reaches default, and tsc points at the line with never:
error TS2322: Type '{ kind: "cancelled"; }' is not assignable to type 'never'.Correcting an order
A separate function corrects the address and the comment. The state plays no part in it, so in this example an order has a number, an address and a comment for the courier:
interface Order {
readonly id: number;
address: string;
comment: string;
}
type OrderPatch = Partial<Omit<Order, "id">>;
function update(order: Order, patch: OrderPatch): Order {
return { ...order, ...patch };
}
const order: Order = { id: 7, address: "Elm 5", comment: "" };
const next = update(order, { address: "Oak 2" });
console.log(next.address, next.id); // Oak 2 7Let's see what happens here:
Omit<Order, "id">removes the number, leavingaddressandcomment.Partial<…>makes them optional:OrderPatchis{ address?: string; comment?: string }.{ ...order, ...patch }builds a new order, and the fields frompatchreplace the old ones.- The program prints
Oak 2 7: a new address, the same number.
A correction cannot carry the number. The call update(order, { id: 99 }) does not compile:
error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'Partial<Omit<Order, "id">>'.Note that Partial makes properties optional but does not remove the readonly mark. Declare const p: Partial<Order> = {}, and its id is read-only too: p.id = 8 is error TS2540.
A result with an error
The total arrives from a form as a string, and it cannot always be read. So the function returns a generic type Result<T>: either a value or the text of an error.
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parseTotal(text: string): Result<number> {
const total = Number(text);
if (Number.isNaN(total) || total <= 0) {
return { ok: false, error: `bad total: ${text}` };
}
return { ok: true, value: total };
}
const r = parseTotal("abc");
console.log(r.ok ? r.value : r.error); // bad total: abcWhat matters in this example:
Result<T>is a discriminated union with the discriminantok: the success variant has avalueof typeT, the failure one has anerror.parseTotalreturnsResult<number>:numberstands in place ofT.r.ok ? r.value : r.errorchecks the discriminant and narrowsr, as anifdoes.- For the string
"abc"the program printsbad total: abc.
You cannot take the value without the check. The line console.log(r.value + 1); does not compile:
error TS2339: Property 'value' does not exist on type 'Result<number>'.
Property 'value' does not exist on type '{ ok: false; error: string; }'.The code that called parseTotal must look at ok first. That is how the rule "the input must be checked" is written down in a type.
Common mistake
The correction was typed as Partial<Order>:
interface Order {
readonly id: number;
address: string;
comment: string;
}
function update(order: Order, patch: Partial<Order>): Order {
return { ...order, ...patch };
}
const order: Order = { id: 7, address: "Elm 5", comment: "" };
const next = update(order, { id: 99 }); // ✗
console.log(next.id);tsc stays silent: Partial<Order> has the property id, and { id: 99 } fits it. The readonly mark does not help here. It forbids only an assignment such as order.id = 99, while update builds a new object, and the number in it comes from patch. The program prints 99, and the order gets a different number. Remove the number from the correction's type: Partial<Omit<Order, "id">>.
The interview simulator
The order model has gathered the whole course: discriminated unions, readonly, utility types, generics and the exhaustiveness check. An interviewer may ask you to sketch a similar model too, so the next step is the TypeScript interview simulator on this site. The "Practice a TypeScript interview" button on the course completion page opens it. The simulator asks about the course's topics and about ones the course did not touch, and grades your answers.
Key points
- The order states are a discriminated union: each variant has its own literal in
kindand its own fields. - A
switchover the discriminant narrows the state in eachcase, andneverindefaultkeeps you from forgetting a new variant. Partial<Omit<Order, "id">>describes a correction without the number;Partialkeepsreadonlybut does not stopidfrom arriving in a new object.Result<T>makes the calling code checkokbefore it takes the value.