Skip to content

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?

Code for this question
// 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); }}
Pick one

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

Many positional parameters. 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?

Code for this question
// 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)}`);
Pick one

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?

Code for this question
// 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));
Pick one

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?

Code for this question
// 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());
Pick one

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?

Code for this question
// 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');
Pick one

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() (sets scanned: true) and at(minute) (sets scannedAt). 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 InvalidTicket from build(); 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?

Code for this question
// 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}`);
Pick one

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?

Code for this question
// 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(' '));
Pick one

Go deeper

Pro tips
  1. 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 default flags drifted to -1 it fails with InvalidAttempt.
  2. 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 without byCandidate() gets TS2684; 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 where clause per if (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.

Checked against the tools and docs as of 2026-10-17.