Project: Typed Order Processing Service
In this project you will build the core of an order-processing service, the kind of backend code that runs food delivery apps, online shops and ticketing systems across India. A cloud bakery in Pune, Sweet Crumbs, takes orders through an app. Every order must be validated, priced with GST, paid through UPI, dispatched with a rider and delivered, and the system must refuse impossible actions such as cancelling an order that has already been eaten.
You will not learn many new features here. Instead, you will combine what the course taught into one program that follows the patterns professional teams use: branded types, unknown input with validation, a Result type for expected failures, a state machine built from a discriminated union, an injected async payment gateway, typed events and exhaustive error handling. The finished program runs in your browser at the end.
What we are building
The service supports these operations:
place(body): accept an order request from the outside world (asunknown), validate it, price it and store it asplaced.pay(id): collect the amount through a payment gateway; on success the order becomespaid.dispatch(id, rider),deliver(id)andcancel(id, reason): move the order through its life.summary(): count orders in each state.
Every operation returns a Result instead of throwing, and every change is announced through a typed event emitter, which a real system would use for notifications and logs. The order life cycle looks like this:
placed --pay--> paid --dispatch--> dispatched --deliver--> delivered
| |
+--cancel------+--cancel--> cancelledStep 1: model the domain
Start with the types, because they decide what the rest of the code can and cannot do.
type Brand<T, B extends string> = T & { readonly __brand: B };
type OrderId = Brand<string, "OrderId">;
type Paise = Brand<number, "Paise">;
const paise = (rupees: number): Paise => Math.round(rupees * 100) as Paise;
const addPaise = (a: Paise, b: Paise): Paise => (a + b) as Paise;
const formatPaise = (p: Paise): string =>
"₹" + (p / 100).toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const MENU = {
"BRW-01": { name: "Chocolate brownie", price: paise(120) },
"CAK-05": { name: "Pineapple cake 500 g", price: paise(549) },
"PUF-02": { name: "Veg puff", price: paise(35) },
} as const satisfies Record<string, { name: string; price: Paise }>;
type Sku = keyof typeof MENU;
type Line = { sku: Sku; qty: number };
type OrderBase = { id: OrderId; customer: string; lines: readonly Line[]; total: Paise };
type Order =
| (OrderBase & { status: "placed" })
| (OrderBase & { status: "paid"; upiRef: string })
| (OrderBase & { status: "dispatched"; upiRef: string; rider: string })
| (OrderBase & { status: "delivered"; upiRef: string; rider: string })
| (OrderBase & { status: "cancelled"; reason: string });Several decisions here are worth copying into your own projects:
- Money is stored in paise, as whole numbers. Floating-point arithmetic cannot represent many decimal fractions exactly (
0.1 + 0.2is not0.3), so payment systems store the smallest currency unit as an integer. ThePaisebrand (see Runtime Validation & Type-Safe APIs) stops anyone from passing a rupee amount where paise are expected. - IDs are branded too. An
OrderIdcannot be confused with a customer name or a UPI reference, although all three are strings at run time. - The menu is a constant with
as const satisfies.satisfieschecks that every item has a name and a price, whileas constkeeps the exact keys, soSkubecomes the union"BRW-01" | "CAK-05" | "PUF-02"(see Literal Types, as const & satisfies). - Each order status is its own union member. Only paid, dispatched and delivered orders have a
upiRef; only cancelled orders have areason. This is the "model states, not flags" idea from Discriminated Unions at Scale.
These types already prevent real bugs. Each of these lines is a compile error:
const wrongTotal: Paise = 549;
function receiptRef(o: Order): string {
return o.upiRef;
}
const typo: Line = { sku: "BRW-1", qty: 2 };app.ts(1,7): error TS2322: Type 'number' is not assignable to type 'Paise'.
Type 'number' is not assignable to type '{ readonly __brand: "Paise"; }'.
app.ts(3,12): error TS2339: Property 'upiRef' does not exist on type 'Order'.
Property 'upiRef' does not exist on type 'OrderBase & { status: "placed"; }'.
app.ts(5,22): error TS2820: Type '"BRW-1"' is not assignable to type '"BRW-01" | "CAK-05" | "PUF-02"'. Did you mean '"BRW-01"'?A receipt can only read upiRef after checking that the order is in a paid-or-later state, which is exactly the business rule.
Step 2: validate untrusted input
Order requests come from a mobile app over HTTP, so the service treats them as unknown and checks every field. Instead of throwing on the first problem, the parser collects all problems, so the app can show them together, and returns a Result (see Error Handling Patterns):
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const fail = <E>(error: E): Result<never, E> => ({ ok: false, error });
type OrderRequest = { customer: string; lines: Line[] };
function isSku(x: unknown): x is Sku {
return typeof x === "string" && Object.hasOwn(MENU, x);
}
function parseOrderRequest(input: unknown): Result<OrderRequest, string[]> {
const problems: string[] = [];
if (typeof input !== "object" || input === null) return fail(["body must be an object"]);
const customer = "customer" in input ? input.customer : undefined;
if (typeof customer !== "string" || customer.trim().length < 2) {
problems.push("customer: at least 2 characters");
}
const rawLines = "lines" in input ? input.lines : undefined;
const lines: Line[] = [];
if (!Array.isArray(rawLines) || rawLines.length === 0) {
problems.push("lines: at least one item");
} else {
rawLines.forEach((raw: unknown, i) => {
const sku = typeof raw === "object" && raw !== null && "sku" in raw ? raw.sku : undefined;
const qty = typeof raw === "object" && raw !== null && "qty" in raw ? raw.qty : undefined;
if (!isSku(sku)) problems.push(`lines[${i}].sku: unknown item ${JSON.stringify(sku)}`);
else if (typeof qty !== "number" || !Number.isInteger(qty) || qty < 1 || qty > 20) {
problems.push(`lines[${i}].qty: whole number from 1 to 20`);
} else lines.push({ sku, qty });
});
}
if (problems.length > 0 || typeof customer !== "string") return fail(problems);
return ok({ customer: customer.trim(), lines });
}Notice how in checks and typeof checks narrow unknown step by step, without a single as. isSku is a type guard, so after it passes, sku has the type Sku and can be pushed into a Line. Object.hasOwn is used instead of sku in MENU, so inherited names such as "toString" are never accepted as menu items. In a larger project you would write the same rules with a schema library such as Zod; the shape of the code around it stays the same.
Step 3: price the order
Pricing is a pure function: the same lines always give the same total, which makes it easy to test.
const GST_RATE = 0.05; // example rate only
function priceOf(lines: readonly Line[]): Paise {
const subtotal = lines.reduce((sum, l) => sum + MENU[l.sku].price * l.qty, 0);
return Math.round(subtotal * (1 + GST_RATE)) as Paise;
}MENU[l.sku].price needs no check for undefined, even with noUncheckedIndexedAccess, because l.sku is a Sku, one of the menu's exact keys. The GST rate is an example value; real rates depend on the product and should come from a configuration that your accountant has confirmed. The result is rounded once, at the end, and branded as Paise.
Step 4: a state machine for the order life cycle
All status changes go through one function. It takes the current order and an Action, and returns either the new order or a TransitionError. Nothing else in the program is allowed to change status.
type Action =
| { type: "pay"; upiRef: string }
| { type: "dispatch"; rider: string }
| { type: "deliver" }
| { type: "cancel"; reason: string };
type TransitionError = { kind: "invalid-transition"; from: Order["status"]; action: Action["type"] };
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function transition(order: Order, action: Action): Result<Order, TransitionError> {
const bad = fail<TransitionError>({ kind: "invalid-transition", from: order.status, action: action.type });
switch (action.type) {
case "pay":
return order.status === "placed" ? ok({ ...order, status: "paid", upiRef: action.upiRef }) : bad;
case "dispatch":
return order.status === "paid" ? ok({ ...order, status: "dispatched", rider: action.rider }) : bad;
case "deliver":
return order.status === "dispatched" ? ok({ ...order, status: "delivered" }) : bad;
case "cancel":
return order.status === "placed" || order.status === "paid"
? ok({ id: order.id, customer: order.customer, lines: order.lines, total: order.total, status: "cancelled", reason: action.reason })
: bad;
default:
return assertNever(action);
}
}The switch on action.type narrows action, so action.upiRef is only available in the "pay" branch. The spread { ...order, status: "paid", upiRef } builds a new object; orders are never mutated, which makes the history easy to reason about. The cancelled order is built field by field, so that a cancelled order never carries a stale upiRef.
The default branch calls assertNever. If a teammate adds a new action, say a refund, without handling it, the build fails immediately:
app.ts(104,26): error TS2345: Argument of type '{ type: "refund"; amount: Paise; }' is not assignable to parameter of type 'never'.Step 5: the payment gateway and typed events
The service must not know which payment provider it talks to. It depends on an interface, and the program passes in an implementation. Here the implementation is a fake UPI gateway that declines amounts above a limit; in production it would call the provider's API, and in tests you would pass a typed mock (see Testing Typed Code).
interface PaymentGateway {
collect(orderId: OrderId, amount: Paise, signal?: AbortSignal): Promise<Result<string, "declined" | "timeout">>;
}
class FakeUpiGateway implements PaymentGateway {
#counter = 9000;
readonly #declineAbove: Paise;
constructor(declineAbove: Paise) {
this.#declineAbove = declineAbove;
}
async collect(orderId: OrderId, amount: Paise): Promise<Result<string, "declined" | "timeout">> {
await new Promise((r) => setTimeout(r, 5));
if (amount > this.#declineAbove) return fail("declined");
this.#counter++;
return ok(`UPI${this.#counter}-${orderId}`);
}
}
type OrderEvents = {
placed: [order: Order];
changed: [order: Order, from: Order["status"]];
rejected: [problems: string[]];
};
class Emitter<E extends Record<string, unknown[]>> {
private handlers: { [K in keyof E]?: ((...args: E[K]) => void)[] } = {};
on<K extends keyof E>(name: K, fn: (...args: E[K]) => void): void {
(this.handlers[name] ??= []).push(fn);
}
emit<K extends keyof E>(name: K, ...args: E[K]): void {
this.handlers[name]?.forEach((fn) => fn(...args));
}
}collect returns a Result whose error is a union of literal reasons, and it accepts an optional AbortSignal so slow providers can be cancelled (see Typing Async Code). The fake gateway uses #private fields and assigns them in the constructor instead of using parameter properties, so the whole file stays erasable and also runs with Node.js type stripping.
The Emitter is the typed event emitter from Designing Generic APIs. OrderEvents maps each event name to a labelled tuple of arguments, so a listener for "changed" receives (order, from) with the right types.
Step 6: the service class
The service ties everything together. Its error type is one union of every failure a caller must handle:
type ServiceError =
| { kind: "invalid-request"; problems: string[] }
| { kind: "not-found"; id: string }
| { kind: "payment-failed"; reason: "declined" | "timeout" }
| TransitionError;
class OrderService {
readonly events = new Emitter<OrderEvents>();
private orders = new Map<OrderId, Order>();
private nextNo = 101;
private readonly gateway: PaymentGateway;
constructor(gateway: PaymentGateway) {
this.gateway = gateway;
}
place(body: unknown): Result<Order, ServiceError> {
const parsed = parseOrderRequest(body);
if (!parsed.ok) {
this.events.emit("rejected", parsed.error);
return fail({ kind: "invalid-request", problems: parsed.error });
}
const id = `PUN-${this.nextNo++}` as OrderId;
const order: Order = { id, ...parsed.value, total: priceOf(parsed.value.lines), status: "placed" };
this.orders.set(id, order);
this.events.emit("placed", order);
return ok(order);
}
private apply(id: OrderId, action: Action): Result<Order, ServiceError> {
const current = this.orders.get(id);
if (!current) return fail({ kind: "not-found", id });
const next = transition(current, action);
if (!next.ok) return next;
this.orders.set(id, next.value);
this.events.emit("changed", next.value, current.status);
return next;
}
async pay(id: OrderId): Promise<Result<Order, ServiceError>> {
const current = this.orders.get(id);
if (!current) return fail({ kind: "not-found", id });
const payment = await this.gateway.collect(id, current.total, AbortSignal.timeout(2000));
if (!payment.ok) return fail({ kind: "payment-failed", reason: payment.error });
return this.apply(id, { type: "pay", upiRef: payment.value });
}
dispatch(id: OrderId, rider: string) { return this.apply(id, { type: "dispatch", rider }); }
deliver(id: OrderId) { return this.apply(id, { type: "deliver" }); }
cancel(id: OrderId, reason: string) { return this.apply(id, { type: "cancel", reason }); }
summary(): Record<Order["status"], number> {
const counts = { placed: 0, paid: 0, dispatched: 0, delivered: 0, cancelled: 0 };
for (const o of this.orders.values()) counts[o.status]++;
return counts;
}
}
function describeError(e: ServiceError): string {
switch (e.kind) {
case "invalid-request": return `Invalid order: ${e.problems.join("; ")}`;
case "not-found": return `No order ${e.id}`;
case "payment-failed": return `Payment ${e.reason}`;
case "invalid-transition": return `Cannot ${e.action} an order that is ${e.from}`;
default: return assertNever(e);
}
}Three design points:
- One private
applymethod performs every synchronous transition: look up, transition, store, emit.dispatch,deliverandcancelare one line each, and their return types are inferred fromapply. payis the only async method, because only it waits for the outside world. It returnsPromise<Result<Order, ServiceError>>, so callers handle a declined payment like any other expected failure.describeErroris exhaustive. A new kind ofServiceErrorcannot be added without a message for users, because thedefaultbranch would stop compiling.
Step 7: the complete program
Here is the whole service in one file, with a small script at the end that places three orders (one invalid), pays, dispatches and delivers the first, tries an illegal cancel, and lets the second payment be declined. Run it and read the output alongside the code.
// ---------- Step 1: domain types ----------
type Brand<T, B extends string> = T & { readonly __brand: B };
type OrderId = Brand<string, "OrderId">;
type Paise = Brand<number, "Paise">;
const paise = (rupees: number): Paise => Math.round(rupees * 100) as Paise;
const addPaise = (a: Paise, b: Paise): Paise => (a + b) as Paise;
const formatPaise = (p: Paise): string =>
"₹" + (p / 100).toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const MENU = {
"BRW-01": { name: "Chocolate brownie", price: paise(120) },
"CAK-05": { name: "Pineapple cake 500 g", price: paise(549) },
"PUF-02": { name: "Veg puff", price: paise(35) },
} as const satisfies Record<string, { name: string; price: Paise }>;
type Sku = keyof typeof MENU;
type Line = { sku: Sku; qty: number };
type OrderBase = { id: OrderId; customer: string; lines: readonly Line[]; total: Paise };
type Order =
| (OrderBase & { status: "placed" })
| (OrderBase & { status: "paid"; upiRef: string })
| (OrderBase & { status: "dispatched"; upiRef: string; rider: string })
| (OrderBase & { status: "delivered"; upiRef: string; rider: string })
| (OrderBase & { status: "cancelled"; reason: string });
// ---------- Step 2: Result and validation ----------
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const fail = <E>(error: E): Result<never, E> => ({ ok: false, error });
type OrderRequest = { customer: string; lines: Line[] };
function isSku(x: unknown): x is Sku {
return typeof x === "string" && Object.hasOwn(MENU, x);
}
function parseOrderRequest(input: unknown): Result<OrderRequest, string[]> {
const problems: string[] = [];
if (typeof input !== "object" || input === null) return fail(["body must be an object"]);
const customer = "customer" in input ? input.customer : undefined;
if (typeof customer !== "string" || customer.trim().length < 2) {
problems.push("customer: at least 2 characters");
}
const rawLines = "lines" in input ? input.lines : undefined;
const lines: Line[] = [];
if (!Array.isArray(rawLines) || rawLines.length === 0) {
problems.push("lines: at least one item");
} else {
rawLines.forEach((raw: unknown, i) => {
const sku = typeof raw === "object" && raw !== null && "sku" in raw ? raw.sku : undefined;
const qty = typeof raw === "object" && raw !== null && "qty" in raw ? raw.qty : undefined;
if (!isSku(sku)) problems.push(`lines[${i}].sku: unknown item ${JSON.stringify(sku)}`);
else if (typeof qty !== "number" || !Number.isInteger(qty) || qty < 1 || qty > 20) {
problems.push(`lines[${i}].qty: whole number from 1 to 20`);
} else lines.push({ sku, qty });
});
}
if (problems.length > 0 || typeof customer !== "string") return fail(problems);
return ok({ customer: customer.trim(), lines });
}
// ---------- Step 3: pricing ----------
const GST_RATE = 0.05; // example rate only
function priceOf(lines: readonly Line[]): Paise {
const subtotal = lines.reduce((sum, l) => sum + MENU[l.sku].price * l.qty, 0);
return Math.round(subtotal * (1 + GST_RATE)) as Paise;
}
// ---------- Step 4: state machine ----------
type Action =
| { type: "pay"; upiRef: string }
| { type: "dispatch"; rider: string }
| { type: "deliver" }
| { type: "cancel"; reason: string };
type TransitionError = { kind: "invalid-transition"; from: Order["status"]; action: Action["type"] };
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function transition(order: Order, action: Action): Result<Order, TransitionError> {
const bad = fail<TransitionError>({ kind: "invalid-transition", from: order.status, action: action.type });
switch (action.type) {
case "pay":
return order.status === "placed" ? ok({ ...order, status: "paid", upiRef: action.upiRef }) : bad;
case "dispatch":
return order.status === "paid" ? ok({ ...order, status: "dispatched", rider: action.rider }) : bad;
case "deliver":
return order.status === "dispatched" ? ok({ ...order, status: "delivered" }) : bad;
case "cancel":
return order.status === "placed" || order.status === "paid"
? ok({ id: order.id, customer: order.customer, lines: order.lines, total: order.total, status: "cancelled", reason: action.reason })
: bad;
default:
return assertNever(action);
}
}
// ---------- Step 5: payment gateway and events ----------
interface PaymentGateway {
collect(orderId: OrderId, amount: Paise, signal?: AbortSignal): Promise<Result<string, "declined" | "timeout">>;
}
class FakeUpiGateway implements PaymentGateway {
#counter = 9000;
readonly #declineAbove: Paise;
constructor(declineAbove: Paise) {
this.#declineAbove = declineAbove;
}
async collect(orderId: OrderId, amount: Paise): Promise<Result<string, "declined" | "timeout">> {
await new Promise((r) => setTimeout(r, 5));
if (amount > this.#declineAbove) return fail("declined");
this.#counter++;
return ok(`UPI${this.#counter}-${orderId}`);
}
}
type OrderEvents = {
placed: [order: Order];
changed: [order: Order, from: Order["status"]];
rejected: [problems: string[]];
};
class Emitter<E extends Record<string, unknown[]>> {
private handlers: { [K in keyof E]?: ((...args: E[K]) => void)[] } = {};
on<K extends keyof E>(name: K, fn: (...args: E[K]) => void): void {
(this.handlers[name] ??= []).push(fn);
}
emit<K extends keyof E>(name: K, ...args: E[K]): void {
this.handlers[name]?.forEach((fn) => fn(...args));
}
}
// ---------- Step 6: the service ----------
type ServiceError =
| { kind: "invalid-request"; problems: string[] }
| { kind: "not-found"; id: string }
| { kind: "payment-failed"; reason: "declined" | "timeout" }
| TransitionError;
class OrderService {
readonly events = new Emitter<OrderEvents>();
private orders = new Map<OrderId, Order>();
private nextNo = 101;
private readonly gateway: PaymentGateway;
constructor(gateway: PaymentGateway) {
this.gateway = gateway;
}
place(body: unknown): Result<Order, ServiceError> {
const parsed = parseOrderRequest(body);
if (!parsed.ok) {
this.events.emit("rejected", parsed.error);
return fail({ kind: "invalid-request", problems: parsed.error });
}
const id = `PUN-${this.nextNo++}` as OrderId;
const order: Order = { id, ...parsed.value, total: priceOf(parsed.value.lines), status: "placed" };
this.orders.set(id, order);
this.events.emit("placed", order);
return ok(order);
}
private apply(id: OrderId, action: Action): Result<Order, ServiceError> {
const current = this.orders.get(id);
if (!current) return fail({ kind: "not-found", id });
const next = transition(current, action);
if (!next.ok) return next;
this.orders.set(id, next.value);
this.events.emit("changed", next.value, current.status);
return next;
}
async pay(id: OrderId): Promise<Result<Order, ServiceError>> {
const current = this.orders.get(id);
if (!current) return fail({ kind: "not-found", id });
const payment = await this.gateway.collect(id, current.total, AbortSignal.timeout(2000));
if (!payment.ok) return fail({ kind: "payment-failed", reason: payment.error });
return this.apply(id, { type: "pay", upiRef: payment.value });
}
dispatch(id: OrderId, rider: string) { return this.apply(id, { type: "dispatch", rider }); }
deliver(id: OrderId) { return this.apply(id, { type: "deliver" }); }
cancel(id: OrderId, reason: string) { return this.apply(id, { type: "cancel", reason }); }
summary(): Record<Order["status"], number> {
const counts = { placed: 0, paid: 0, dispatched: 0, delivered: 0, cancelled: 0 };
for (const o of this.orders.values()) counts[o.status]++;
return counts;
}
}
function describeError(e: ServiceError): string {
switch (e.kind) {
case "invalid-request": return `Invalid order: ${e.problems.join("; ")}`;
case "not-found": return `No order ${e.id}`;
case "payment-failed": return `Payment ${e.reason}`;
case "invalid-transition": return `Cannot ${e.action} an order that is ${e.from}`;
default: return assertNever(e);
}
}
// ---------- Step 7: run it ----------
const service = new OrderService(new FakeUpiGateway(paise(2000)));
service.events.on("placed", (o) => console.log(`[placed] ${o.id} for ${o.customer}: ${formatPaise(o.total)}`));
service.events.on("changed", (o, from) => console.log(`[${from} -> ${o.status}] ${o.id}`));
service.events.on("rejected", (p) => console.log(`[rejected] ${p.length} problem(s)`));
const show = <T>(r: Result<T, ServiceError>): void => {
if (!r.ok) console.log(" error:", describeError(r.error));
};
const requests: unknown[] = [
{ customer: "Ananya", lines: [{ sku: "CAK-05", qty: 1 }, { sku: "PUF-02", qty: 4 }] },
{ customer: "Rohan", lines: [{ sku: "BRW-01", qty: 20 }] },
{ customer: "M", lines: [{ sku: "LADDU", qty: 2 }, { sku: "PUF-02", qty: 0 }] },
];
const placed: OrderId[] = [];
for (const body of requests) {
const r = service.place(body);
if (r.ok) placed.push(r.value.id);
else show(r);
}
const [first, second] = placed;
if (first && second) {
show(await service.pay(first));
show(service.dispatch(first, "Farhan"));
show(service.deliver(first));
show(service.cancel(first, "changed my mind"));
show(await service.pay(second));
show(service.cancel(second, "payment failed twice"));
}
show(service.deliver("PUN-999" as OrderId));
console.log(service.summary());[placed] PUN-101 for Ananya: ₹723.45
[placed] PUN-102 for Rohan: ₹2,520.00
[rejected] 3 problem(s)
error: Invalid order: customer: at least 2 characters; lines[0].sku: unknown item "LADDU"; lines[1].qty: whole number from 1 to 20
[placed -> paid] PUN-101
[paid -> dispatched] PUN-101
[dispatched -> delivered] PUN-101
error: Cannot cancel an order that is delivered
error: Payment declined
[placed -> cancelled] PUN-102
error: No order PUN-999
{ placed: 0, paid: 0, dispatched: 0, delivered: 1, cancelled: 1 }Follow the story in the output. Ananya's order totals ₹723.45 (a ₹549 cake and four ₹35 puffs is ₹689, plus 5% example GST). The third request is rejected with all three problems listed at once. The first order moves through every state, and the attempt to cancel it after delivery is refused by the state machine. Rohan's ₹2,520 order is above the fake gateway's ₹2,000 limit, so payment is declined, and the order is cancelled instead. Asking for PUN-999 gives a typed not-found error. The summary counts are typed as Record<Order["status"], number>, so a new status would have to appear there too.
Running it as a real Node.js project
To run the service outside the browser, save it as src/orders.ts in a project set up as in TypeScript with Node.js. Because the file uses only erasable syntax, Node.js can run it directly:
node src/orders.ts
npx tsc --noEmitNode.js strips the types and runs the code, so keep tsc --noEmit (and a linter) in your workflow to check them. The program also compiles cleanly with the stricter options from Strict tsconfig Options, including noUncheckedIndexedAccess, exactOptionalPropertyTypes and erasableSyntaxOnly.
Ideas to extend the project
- Add a
refundaction for paid orders. Follow the compiler errors:transition,describeErrorand possibly the events all need updates. - Replace
parseOrderRequestwith a Zod schema and compare the code size and error messages. - Split the file into modules (
money.ts,menu.ts,orders.ts,service.ts) and write Vitest tests fortransitionandpriceOf, with a typed mock gateway. - Put an Express 5 route in front of
placeandpay, mapping eachServiceErrorkind to an HTTP status code (400,404,402,409). - Store orders in a database instead of a
Map, behind anOrderRepositoryinterface, so the service code does not change.
Key points
- Model money as integer paise with a brand, and IDs as branded strings, so units and identifiers cannot be mixed up.
- Treat all incoming data as
unknown, collect every validation problem, and return aResult. - A discriminated union for order states plus one
transitionfunction turns business rules into compile-time checks. - Depend on interfaces such as
PaymentGateway, so real providers, fakes and mocks are interchangeable. - Typed events and an exhaustive error union keep every part of the system honest when new features are added.
Exercise
Add a refund action to the service. A refund is allowed only for paid or dispatched orders, it moves the order to a new status "refunded" that keeps upiRef and stores refundedAt (a string), and the summary must count refunded orders. Make the changes, let the compiler guide you, and then refund a newly paid order in the demo script.
Show solution
// 1. New member in the Order union
type Order =
| (OrderBase & { status: "placed" })
| (OrderBase & { status: "paid"; upiRef: string })
| (OrderBase & { status: "dispatched"; upiRef: string; rider: string })
| (OrderBase & { status: "delivered"; upiRef: string; rider: string })
| (OrderBase & { status: "refunded"; upiRef: string; refundedAt: string })
| (OrderBase & { status: "cancelled"; reason: string });
// 2. New action
type Action =
| { type: "pay"; upiRef: string }
| { type: "dispatch"; rider: string }
| { type: "deliver" }
| { type: "refund"; at: string }
| { type: "cancel"; reason: string };
// 3. New case in transition (assertNever pointed here)
case "refund":
return order.status === "paid" || order.status === "dispatched"
? ok({ id: order.id, customer: order.customer, lines: order.lines, total: order.total,
status: "refunded", upiRef: order.upiRef, refundedAt: action.at })
: bad;
// 4. Service method and summary
refund(id: OrderId, at: string) { return this.apply(id, { type: "refund", at }); }
// summary(): add refunded: 0 to the counts object
// 5. Demo
const r = service.place({ customer: "Meera", lines: [{ sku: "BRW-01", qty: 2 }] });
if (r.ok) {
show(await service.pay(r.value.id));
show(service.refund(r.value.id, "2026-11-02 18:30"));
}After step 2 the compiler reports the missing case through assertNever, and after step 1 it reports the summary() counts object as missing refunded, because its return type is Record<Order["status"], number>. Inside the "refund" case, the check order.status === "paid" || order.status === "dispatched" narrows order, so order.upiRef is available without a cast.
Quick Quiz
1. Why does the service store money as integer paise instead of rupees with decimals?
Payment systems commonly store the smallest currency unit as an integer.
2. What stops code from reading upiRef on a placed order?
Each state carries only its own fields, so the type forces the business rule.
3. Why does OrderService receive a PaymentGateway in its constructor?
Depending on an interface keeps the service testable and provider-independent.
4. A developer adds a new Action member but forgets to handle it. What happens?
The unhandled member is not assignable to never, so the build fails.
Learn TypeScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
