Skip to content
Theory · A TypeScript project · lesson 9 of 10

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:

TypeScript
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 paid

Let's go through the example line by line:

  1. OrderState has three variants, and each has its own literal in kind and its own fields.
  2. In Order the number is marked readonly, and the state lives in the state field.
  3. order.state = { kind: "paid", total: 500 } replaces the state as a whole: the order is now paid.
  4. 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:

TEXT
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.

LiveWhat TypeScript sees in each casestep 1 of 5

On the way in, s may be any of the three order states.

Press "Next step": the hover shows the type on the highlighted line

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:

TEXT
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:

TypeScript
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 7

Let's see what happens here:

  1. Omit<Order, "id"> removes the number, leaving address and comment.
  2. Partial<…> makes them optional: OrderPatch is { address?: string; comment?: string }.
  3. { ...order, ...patch } builds a new order, and the fields from patch replace the old ones.
  4. 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:

TEXT
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.

TypeScript
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: abc

What matters in this example:

  1. Result<T> is a discriminated union with the discriminant ok: the success variant has a value of type T, the failure one has an error.
  2. parseTotal returns Result<number>: number stands in place of T.
  3. r.ok ? r.value : r.error checks the discriminant and narrows r, as an if does.
  4. For the string "abc" the program prints bad total: abc.

You cannot take the value without the check. The line console.log(r.value + 1); does not compile:

TEXT
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>:

TypeScript
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 kind and its own fields.
  • A switch over the discriminant narrows the state in each case, and never in default keeps you from forgetting a new variant.
  • Partial<Omit<Order, "id">> describes a correction without the number; Partial keeps readonly but does not stop id from arriving in a new object.
  • Result<T> makes the calling code check ok before it takes the value.