A builder, and why TypeScript mostly uses options objects
Name your arguments with an options object; keep a builder for objects assembled in parts, and for tests.
Predict first
Before you read: predict
ExamHall's grading code creates attempts with createAttempt; its tests build them with AttemptBuilder. A new test leaves out the candidate in both. The files are below.
What do tsc and a run of main.ts report?
// attempt.tsexport class MissingField extends Error {}export interface Attempt { examId: string; candidateId: string; flags: number }export interface AttemptOptions { examId: string; candidateId: string; flags?: number }export function createAttempt({ examId, candidateId, flags = 0 }: AttemptOptions): Attempt { return { examId, candidateId, flags };}export class AttemptBuilder { private examId = ''; private candidateId = ''; private flags = 0; inExam(id: string) { this.examId = id; return this; } byCandidate(id: string) { this.candidateId = id; return this; } withFlags(n: number) { this.flags = n; return this; } build(): Attempt { if (!this.examId || !this.candidateId) throw new MissingField(); return { examId: this.examId, candidateId: this.candidateId, flags: this.flags }; }} // main.tsimport { createAttempt, AttemptBuilder } from './attempt';const makers = [ () => createAttempt({ examId: 'ECON-201', flags: 4 }), () => new AttemptBuilder().inExam('ECON-201').withFlags(4).build(),];for (const make of makers) { try { console.log(JSON.stringify(make())); } catch (e) { console.log((e as Error).constructor.name); }}The 30-second version
Positional arguments name none of their values, so a long list is easy to get wrong and hard to review. An options object names every argument and defaults the optional ones; its type keeps the rest required. A builder sets values in steps and makes the object in build(). It pays for queries assembled in parts, and for test data.
Name your arguments with an options object; keep a builder for objects assembled in parts, and for tests.
How it works
Step 1 of 12
new Attempt('ECON-201', 'c-17', 4, true) names none of its values; a reader, and a reviewer, must look each one up.In the kitchen
- the fitter phones the workshop "1200, 1500, 2": the cutter must remember which number is the width and which the drop → positional parameters (step 1)
Analogy: A made-to-measure window-blind company
A fitter measures each window and the workshop makes the blind.
| At the blind company | In code |
|---|---|
| the fitter phones the workshop "1200, 1500, 2": the cutter must remember which number is the width and which the drop | positional parameters (step 1) |
| the order form's labelled boxes, with the optional ones pre-printed in grey ("chain: white, left") | an options object with defaults (steps 3–4) |
| the job sheet travelling from bench to bench, each bench filling in its own part before the blind is made | a builder (step 5) |
| the showroom's sample blind: standard in every way except the feature a salesperson is showing | a test-data builder (step 10) |
In real terms: reach for an options object first; write a builder when the object is assembled in parts, or when forty tests each need one slightly different.
Breaks down when: the cutter can ring the fitter about a drop of 15 mm; build() cannot ask anyone, it can only throw.
Contrasting case: a ready-made blind sold in three sizes needs no order form at all: a function with two arguments.
Examples, run for real
4.1 ExamHall: forty grading tests and one new field
EXH-4810: ExamHall's 40 grading tests each wrote a whole Attempt literal, every one of its eight fields, though each check read two or three. When Attempt gained a required proctorId, every test stopped compiling.
MR !5110 adds anAttempt(), a test-data builder whose defaults form one valid attempt; each test states only what its check reads:
// grading-1.test.ts, MR !5110
export const t1 = anAttempt().submittedAt('10:00').withFlags(0).build();
4.1 EXH-4810 as shipped: 40 tests in 4 files · values written per test 8.0 · add a required proctorId: TS2741 ×40 in 4 files (grading-1.test.ts 10, grading-2.test.ts 10, grading-3.test.ts 10, grading-4.test.ts 10)
4.1 MR !5110: 40 tests in 4 files · values written per test 1.8 · add a required proctorId: TS2741 ×1 in 1 file (attempt-builder.ts 1)
Before the MR, a new field meant 40 edits and every test hid its point among eight values. After it, the new field is one default in the builder, and each test reads as the one or two facts it checks (step 10).
4.2 What the MR does not change
Production code still calls createAttempt({ examId, candidateId }). Its options type makes a call that leaves out the candidate a compile error, which no builder in production would give (steps 4, 7, 12).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A box office's tests build seat holds with HoldBuilder. Its new test keeps one builder as a base for two holds, and the test expects lapsed hold frees B7: true · live hold frees B7: false. hold.ts and main.ts are the same in every version; hold-builder.ts comes in three versions. The files are below.
Which version makes main.ts print what the test expects, with main.ts unchanged?
// hold.tsexport interface Hold { seatId: string; status: 'held' | 'expired'; expiresAt: number }export const releasesSeat = (h: Hold) => h.status === 'expired'; // hold-builder.ts (version 1)import type { Hold } from './hold';export class HoldBuilder { private hold: Hold = { seatId: 'A1', status: 'held', expiresAt: 600 }; forSeat(seatId: string) { this.hold.seatId = seatId; return this; } expired() { this.hold.status = 'expired'; return this; } build(): Hold { return JSON.parse(JSON.stringify(this.hold)); }} // hold-builder.ts (version 2)import type { Hold } from './hold';export class HoldBuilder { constructor(private hold: Hold = { seatId: 'A1', status: 'held', expiresAt: 600 }) {} forSeat(seatId: string) { return new HoldBuilder({ ...this.hold, seatId }); } expired() { return new HoldBuilder({ ...this.hold, status: 'expired' }); } build(): Hold { return { ...this.hold }; }} // hold-builder.ts (version 3)import type { Hold } from './hold';export class HoldBuilder { constructor(private hold: Hold = { seatId: 'A1', status: 'held', expiresAt: 600 }) {} forSeat(seatId: string) { const next = new HoldBuilder(this.hold); next.hold.seatId = seatId; return next; } expired() { const next = new HoldBuilder(this.hold); next.hold.status = 'expired'; return next; } build(): Hold { return { ...this.hold }; }} // main.tsimport { HoldBuilder } from './hold-builder';import { releasesSeat } from './hold';const base = new HoldBuilder().forSeat('B7');const lapsed = base.expired().build();const live = base.build();console.log(`lapsed hold frees B7: ${releasesSeat(lapsed)} · live hold frees B7: ${releasesSeat(live)}`);Question 2 of 7
A box office releases seat holds that have run out. holdsToRelease builds its query with Kysely; db is a Kysely 0.29.6 instance for PostgreSQL that compiles queries without a database (db.ts, not shown). The files are below.
What does main.ts print?
// holds-query.tsimport { db } from './db';export function holdsToRelease(eventId: string | undefined, now: number) { let q = db.selectFrom('holds').select('seat_id').where('status', '=', 'held'); if (eventId) q.where('event_id', '=', eventId); q = q.where('expires_at', '<', now); return q.compile().sql;} // main.tsimport { holdsToRelease } from './holds-query';console.log(holdsToRelease('EVT-9', 600));Question 3 of 7
The box office's rule: one buyer may order at most 4 seats, a group at most 10. order.ts and main.ts are the same in every version; order-builder.ts comes in three versions. The files are below.
Which version builds exactly the orders the rule allows and refuses the others?
// order.tsexport class InvalidOrder extends Error {}export interface Order { seats: number; maxSeats: number } // order-builder.ts (version 1)import { InvalidOrder, type Order } from './order';export class OrderBuilder { private seats = 1; private maxSeats = 4; withSeats(n: number) { this.seats = n; return this; } forGroup() { this.maxSeats = 10; if (this.seats > this.maxSeats) throw new InvalidOrder(); return this; } build(): Order { return { seats: this.seats, maxSeats: this.maxSeats }; }} // order-builder.ts (version 2)import { InvalidOrder, type Order } from './order';export class OrderBuilder { private seats = 1; private maxSeats = 4; withSeats(n: number) { if (n > this.maxSeats) throw new InvalidOrder(); this.seats = n; return this; } forGroup() { this.maxSeats = 10; return this; } build(): Order { return { seats: this.seats, maxSeats: this.maxSeats }; }} // order-builder.ts (version 3)import { InvalidOrder, type Order } from './order';export class OrderBuilder { private seats = 1; private maxSeats = 4; withSeats(n: number) { this.seats = n; return this; } forGroup() { this.maxSeats = 10; return this; } build(): Order { if (this.seats > this.maxSeats) throw new InvalidOrder(); return { seats: this.seats, maxSeats: this.maxSeats }; }} // main.tsimport { OrderBuilder } from './order-builder';const attempt = (label: string, make: () => unknown) => { try { make(); console.log(`${label}: built`); } catch (e) { console.log(`${label}: ${(e as Error).constructor.name}`); }};attempt('group, 6 seats', () => new OrderBuilder().withSeats(6).forGroup().build());attempt('one buyer, 6 seats', () => new OrderBuilder().withSeats(6).build());Question 4 of 7
A box office's MR changes the default expiry from 600 to 300 seconds in its test-data builder for seat holds. The suite's three checks passed before the MR. The files are below.
Which checks fail after the MR?
// hold.tsexport interface Hold { seatId: string; priceCents: number; expiresAt: number }export const reminderAt = (h: Hold) => h.expiresAt - 60;export const priceLabel = (h: Hold) => `€${(h.priceCents / 100).toFixed(2)}`; // hold-builder.ts (after the MR)import type { Hold } from './hold';const DEFAULTS: Hold = { seatId: 'A1', priceCents: 4000, expiresAt: 300 }; // was 600export class HoldBuilder { constructor(private readonly fields: Hold = DEFAULTS) {} withExpiry(expiresAt: number) { return new HoldBuilder({ ...this.fields, expiresAt }); } withPrice(priceCents: number) { return new HoldBuilder({ ...this.fields, priceCents }); } build(): Hold { return { ...this.fields }; }}export const aHold = () => new HoldBuilder(); // main.tsimport { aHold } from './hold-builder';import { reminderAt, priceLabel } from './hold';const check = (name: string, ok: boolean) => console.log(`${ok ? 'pass' : 'FAIL'} ${name}`);check('reminder a minute before expiry', reminderAt(aHold().build()) === 540);check('reminder for a 15-minute hold', reminderAt(aHold().withExpiry(900).build()) === 840);check('price label', priceLabel(aHold().withPrice(1250).build()) === '€12.50');Question 5 of 7
Write aTicket() for a box office's tests, from a blank file that imports InvalidTicket from ./ticket.mjs and exports aTicket. A ticket is { eventId, seat, priceCents, scanned, scannedAt }.
aTicket().build()with no steps gives{ eventId: 'EVT-1', seat: 'A1', priceCents: 4000, scanned: false, scannedAt: null }.- Steps:
forSeat(seat),pricedAt(cents),scanned()(setsscanned: true) andat(minute)(setsscannedAt). They chain in any order, and a builder kept in a variable can serve as the base of several tickets. - The ticket's rules: the price is a whole number of cents, 0 or more; a scanned ticket has a scan time and an unscanned one has none. A ticket that breaks one throws
InvalidTicketfrombuild(); no step throws. - Each
build()returns a new object.
Write aTicket in full.
Question 6 of 7
A ledger records transfers through two functions. The files are below.
What do tsc and a run of main.ts report?
// transfer.tsexport interface Transfer { from: string; to: string; amountCents: number; feeCents: number }export function transfer(from: string, to: string, amountCents: number, feeCents: number): Transfer { return { from, to, amountCents, feeCents };}export function transferOf(t: Transfer): Transfer { return { ...t };} // main.tsimport { transfer, transferOf } from './transfer';const feeCents = 250;const amountCents = 10000;const a = transfer('acc-1', 'acc-2', feeCents, amountCents);const b = transferOf({ from: 'acc-1', to: 'acc-2', feeCents, amountCents });console.log(`positional: ${a.amountCents} · options: ${b.amountCents}`);Question 7 of 7
A team chat posts a launch notice to three people from one base builder; a user who has muted the room gets the message silently. The files are below.
What does main.ts print?
// message-builder.tsexport interface Message { room: string; to: string; text: string; silent: boolean }export class MessageBuilder { constructor(private m: Message = { room: 'general', to: '', text: '', silent: false }) {} inRoom(room: string) { return new MessageBuilder({ ...this.m, room }); } to(user: string) { return new MessageBuilder({ ...this.m, to: user }); } saying(text: string) { return new MessageBuilder({ ...this.m, text }); } silently() { this.m.silent = true; return this; } build(): Message { return { ...this.m }; }} // main.tsimport { MessageBuilder } from './message-builder';const users = [{ id: 'ana', muted: false }, { id: 'ben', muted: true }, { id: 'cy', muted: false }];const b = new MessageBuilder().inRoom('launch').saying('Deploy starts at 14:00');const sent = users.map((u) => { if (u.muted) b.silently(); return b.to(u.id).build(); });console.log(sent.map((m) => `${m.to}:${m.silent ? 'silent' : 'ping'}`).join(' '));Go deeper
Pro tips
- Send the builder's defaults through production's own check. One test builds
anAttempt().build()with no steps and passes it to the validation production uses; a default that drifts out of range then fails that one named test. When: adding or changing a default in a test-data builder (step 10). Verify (RAN,tips.mjs): with valid defaults the test passes; with the defaultflagsdrifted to-1it fails withInvalidAttempt. - When a production builder must not skip a step, put the step in its type. A staged builder returns, from the required step, a type that
build()asks for, so a chain that skips it is a compile error instead of a run-time throw. When: a builder used in production code where a missing value is costly (step 7). Verify (RAN,tips.mjs): a chain withoutbyCandidate()getsTS2684; the chain with it compiles.
Source
- Martin Fowler, Object Mother (bliki, 24 October 2006; the row's second source): https://martinfowler.com/bliki/ObjectMother.html · fetched 2026-10-17. Teaching only; every key here is executed. "An object mother is a kind of class used in testing to help create example objects that you use for testing." and "This set data is generally referred to as the test fixture." (steps 10–11); "Object Mothers do have their faults. In particular there's a heavy coupling in that many tests will depend on the exact data in the mothers. As a result it's tricky should you want to change that standard data for any reason." (step 11). Valid as of 2026-10-17.
- Kysely, Conditional where calls (examples page; not named by the row, so it keys nothing and supports teaching only): https://kysely.dev/docs/examples/where/conditional-where-calls · fetched 2026-10-17. Its example adds one
whereclause perif(step 9). Valid as of 2026-10-17. - The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), is a book and keys nothing.
quote-check.mjs finds every passage quoted here verbatim in its saved text.
Related lessons
Checked against the tools and docs as of 2026-10-17.