← Design patterns
Behavior Optional collaborators and explicit policy

Null object

Make absence intentional.

A checkout prices an order whether or not anyone needs a line-by-line explanation. The total still has to be correct. How can the pricing call the same method in both cases without asking every step whether an explanation exists?

TypeScriptGoRust The same pricing and the same no-op in each language.

Definition

A null object stands in for a collaborator that is intentionally absent: it shares the collaborator’s interface and does nothing, and it keeps the decision of how to do nothing out of the code that uses it.

After Bobby Woolf, Pattern Languages of Program Design 3 (1998), Null Object

01When silence is valid

Doing nothing can be a valid behavior.

A checkout prices an order for two callers. The “why this price?” view shows each amount as it is decided: the subtotal, the discount, shipping, tax and the total. The invoice batch prices thousands of orders and explains none of them. An optional trace is a reasonable start: pricing notes each amount if it was given a trace, and the batch passes none.

quote.ts
// Before: the trace is optional, so every step asks whether it has one.
function priceOrder(
  items: Item[], code: string, region: string,
  rates: MemoryRates, trace?: QuoteTrace,
): Quote {
  const percent = rates.percentFor(region);
  let subtotal = 0;
  for (const item of items) subtotal += item.unitCents * item.quantity;
  trace?.note({ label: 'subtotal', cents: subtotal });
  const discount = code === 'WELCOME10' ? Math.floor(subtotal / 10) : 0;
  trace?.note({ label: `discount ${code}`, cents: discount });
  const taxable = subtotal - discount;
  const shipping = taxable >= 5000 ? 0 : 500;
  trace?.note({ label: 'shipping', cents: shipping });
  const tax = Math.floor((taxable * percent + 50) / 100);
  trace?.note({ label: `tax ${region} ${percent}%`, cents: tax });
  const total = taxable + shipping + tax;
  trace?.note({ label: 'total', cents: total });
  return { subtotal, discount, shipping, tax, total };
}

// The invoice batch passes no trace at all.
const invoice = priceOrder(items, 'WELCOME10', 'north', rates);

This function uses the program’s types, shown in full below. With one caller and one note, trace?.note(…) is the simplest code, and strict TypeScript will not let you forget the ?.. But the caller’s choice now travels through code that does not own it: every amount asks again, and every helper that pricing grows takes the optional trace and asks too.

A null object is a real collaborator that implements the usual contract with an intentional neutral behavior. The caller selects noTrace once. Pricing calls note as it normally would; the selected trace decides whether to keep the explanation.

The useful question is whether the effect may disappear without changing what success means. An explanation can disappear. A tax rate cannot: a missing rate must remain a failure instead of quietly producing a smaller total.

02Follow the quote

The caller chooses the explanation once.

The program prices the same two notebooks and four pens twice: once for the “why this price?” view, with a recording trace, and once for the invoice batch, with noTrace. Then it asks for a region that has no rate. Pricing reads the rate first, then notes the subtotal, discount, shipping, tax and total as each amount is computed. This is the whole program the lab runs.

quote.ts
type TraceLine = { label: string; cents: number };

// A trace accepts explanation lines. It does not promise to keep them.
interface QuoteTrace {
  note(line: TraceLine): void;
}

// Keeps every line, for the "why this price?" view.
class RecordingTrace implements QuoteTrace {
  lines: TraceLine[] = [];
  note(line: TraceLine): void {
    this.lines.push(line);
  }
}

// The null object: a real trace whose note keeps nothing.
const noTrace: QuoteTrace = {
  note(): void {},
};

type Item = { name: string; unitCents: number; quantity: number };
type Quote = {
  subtotal: number;
  discount: number;
  shipping: number;
  tax: number;
  total: number;
};

// Required: a rate for a known region, or an error. Never a made-up zero.
class MemoryRates {
  percents = new Map([['north', 8], ['south', 5]]);
  percentFor(region: string): number {
    const percent = this.percents.get(region);
    if (percent === undefined) throw new Error(`unknown-region-${region}`);
    return percent;
  }
}

// Pricing calls note at every step and never asks which trace it has.
function priceOrder(
  items: Item[], code: string, region: string,
  rates: MemoryRates, trace: QuoteTrace,
): Quote {
  const percent = rates.percentFor(region);
  let subtotal = 0;
  for (const item of items) subtotal += item.unitCents * item.quantity;
  trace.note({ label: 'subtotal', cents: subtotal });
  const discount = code === 'WELCOME10' ? Math.floor(subtotal / 10) : 0;
  trace.note({ label: `discount ${code}`, cents: discount });
  const taxable = subtotal - discount;
  const shipping = taxable >= 5000 ? 0 : 500;
  trace.note({ label: 'shipping', cents: shipping });
  const tax = Math.floor((taxable * percent + 50) / 100);
  trace.note({ label: `tax ${region} ${percent}%`, cents: tax });
  const total = taxable + shipping + tax;
  trace.note({ label: 'total', cents: total });
  return { subtotal, discount, shipping, tax, total };
}

const rates = new MemoryRates();
const items: Item[] = [
  { name: 'Notebook', unitCents: 1200, quantity: 2 },
  { name: 'Pen', unitCents: 300, quantity: 4 },
];

// The "why this price?" view explains; the invoice batch does not.
const recording = new RecordingTrace();
const explained = priceOrder(items, 'WELCOME10', 'north', rates, recording);
const silent = priceOrder(items, 'WELCOME10', 'north', rates, noTrace);
const kept = recording.lines.length;
const sameTotal = explained.total === silent.total;

// A missing rate is a failure in either mode, not a zero.
let missing = '';
try {
  priceOrder(items, 'WELCOME10', 'island', rates, noTrace);
} catch (error) {
  missing = (error as Error).message;
}

console.log(`explained: total ${explained.total}, ${kept} lines kept`);
console.log(`silent: total ${silent.total}, same total: ${sameTotal}`);
console.log(`island: ${missing}`);
Who decides what, and what each promises
OwnerDecisionPromise
CallerWhether the explanation is kept.Handles the quote or its failure.
PricingValidation order and the five amounts.Returns a complete quote or a named pricing failure.
RatesThe configured percentage for a known region.Never substitutes zero for an unknown region.
TraceWhether explanation lines are kept.Accepts each line synchronously and does not fail.
What a production quote addsValidation, typed failures and copies

The same pricing, checked. Items are already parsed. Pricing rejects an empty order, then an invalid quantity or price, then an unknown discount code, then a region without a configured rate, and each failure has a name. All of them happen before the first note, so a failed quote leaves the trace empty. Rates become an interface, so pricing does not care where they come from, and a missing rate is an error, never zero.

The recorder copies the lines it is given and hands out copies, and it belongs to one quote: reused, it accumulates. The no-op has no state, so one can be shared. It is frozen, so no holder can swap its note for another, and pricing copies the items before reading them.

quote.ts
export type TraceLine = Readonly<{ label: string; cents: number }>;

// Explanations are optional, synchronous and non-throwing by contract.
export interface QuoteTrace {
	note(line: TraceLine): void;
}

// The null object, frozen so no holder can swap its note for another.
export const noTrace: QuoteTrace = Object.freeze({
	note(): void {}
});

// Keeps copies of its lines and hands out copies, one recorder per quote.
export class RecordingTrace implements QuoteTrace {
	#lines: TraceLine[] = [];
	note(line: TraceLine): void {
		this.#lines.push({ ...line });
	}
	snapshot(): TraceLine[] {
		return this.#lines.map((line) => ({ ...line }));
	}
}

export type Item = Readonly<{
	name: string;
	unitCents: number;
	quantity: number;
}>;
export type Quote = Readonly<{
	subtotal: number;
	discount: number;
	shipping: number;
	tax: number;
	total: number;
}>;

// A whole rate from 0 to 100, or a QuoteFailure for an unknown region.
export interface TaxRates {
	percentFor(region: string): number;
}

export class QuoteFailure extends Error {
	constructor(code: string) {
		super(code);
		this.name = 'QuoteFailure';
	}
}

const codes = ['', 'WELCOME10', 'FREESHIP'];
const whole = (value: number, least: number) =>
	Number.isInteger(value) && value >= least;

export function priceOrder(
	items: readonly Item[],
	code: string,
	region: string,
	rates: TaxRates,
	trace: QuoteTrace
): Quote {
	const input = items.map((item) => ({ ...item }));
	if (input.length === 0) throw new QuoteFailure('empty-order');
	input.forEach((item, index) => {
		if (!whole(item.quantity, 1) || !whole(item.unitCents, 0))
			throw new QuoteFailure(`invalid-item-${index + 1}`);
	});
	if (!codes.includes(code)) throw new QuoteFailure(`unknown-code-${code}`);
	const percent = rates.percentFor(region);
	const subtotal = input.reduce(
		(sum, item) => sum + item.unitCents * item.quantity,
		0
	);
	trace.note({ label: 'subtotal', cents: subtotal });
	const discount = code === 'WELCOME10' ? Math.floor(subtotal / 10) : 0;
	trace.note({ label: `discount ${code || 'none'}`, cents: discount });
	const taxable = subtotal - discount;
	const shipping = code === 'FREESHIP' || taxable >= 5000 ? 0 : 500;
	trace.note({ label: 'shipping', cents: shipping });
	const tax = Math.floor((taxable * percent + 50) / 100);
	trace.note({ label: `tax ${region} ${percent}%`, cents: tax });
	const total = taxable + shipping + tax;
	trace.note({ label: 'total', cents: total });
	return Object.freeze({ subtotal, discount, shipping, tax, total });
}

The shared examples use whole cents, quantities and percentages small enough that the arithmetic stays exact in each language; production pricing validates its own bounds. WELCOME10 takes a tenth of the subtotal, rounded down; shipping is free with FREESHIP or from 50.00; tax rounds half up. These are teaching rules, not a tax policy, and the regions are fictional.

03Predict what stands in

Is everything that type-checks a trace?

Experiment

One order, two traces

quote.ts
const silent = priceOrder(
  items, 'WELCOME10', 'north', rates, {} as QuoteTrace);
Suppose the silent quote were handed an empty object cast to the interface, as above: line 70 becomes these two lines. What does the program do?
Reading the TypeScriptStructural interfaces, casts and ?.

An object satisfies an interface by having its members, so the object literal noTrace and the class RecordingTrace are both a QuoteTrace. An assertion such as as QuoteTrace adds nothing to the object; it only stops the compiler checking. A void return says the caller uses no value; it does not prove that a method cannot throw.

The guard and the null object differ in one more way. trace?.note(…) with no trace does not evaluate its argument at all; noTrace.note(…) receives the line, already built, and drops it. Building a label is cheap here. If it were not, pass something that builds the line only when asked, but only if avoiding that work is part of the requirement.

04Meet it in your language

Doing nothing still keeps the promise.

In Node you meet null objects wherever an API takes a destination: a stream, a logger, a signal. A CLI’s --quiet flag can send an export to a Writable that drops what it gets. The bug you meet is one that drops the chunk and never calls its callback: the stream waits for that answer before it hands over the next chunk, and the export never finishes.

discard.ts
import { Readable, Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

// Careless: it accepts a chunk and never says it is done, so the stream
// waits for that answer before it hands over the next chunk.
export const careless = () => new Writable({ write() {} });

// The null object for a stream: it drops each chunk and says it is done.
export const discard = () =>
  new Writable({
    write(_chunk, _encoding, done) {
      done();
    },
  });

// The export the CLI's --quiet flag sends nowhere.
export async function exportReport(rows: string[], out: Writable) {
  await pipeline(Readable.from(rows.map((row) => `${row}\n`)), out);
  return rows.length;
}

A null object keeps the whole contract, including the part that says “I’m done”. Here that is calling done() for every chunk (in Node 22, stream/promises gives you pipeline to wait for the end). The same holds for a silent logger: each method must exist and return what its callers expect.

05Keep the promise

Choose a no-op only for a neutral effect.

Use a null object when the behavior has a defined neutral form, the caller selects it intentionally, and clients can keep using the contract without losing information they need. Keep a simple optional check when there is one caller and the check is easier to understand. Keep an explicit missing value or result when the caller must decide what absence means.

What would you have to change tomorrow?
The changeAn optional trace, checked at each stepA null object chosen once
A sixth amount, a gift-card credit, is notedAnother check whether a trace exists.Nothing: the new step calls note like the others.
Shipping moves into its own function, which notesThe helper takes the optional trace too, and asks again.The helper takes a trace and calls it.
Every quote’s reasoning must be stored with the orderAn optional trace is now the wrong type.Doing nothing no longer keeps the contract: remove the no-op.
The tax-rate table fails to loadA failure, reported to the caller.Still a failure: a rates object answering 0 would undercharge.
One caller only ever notes one lineOne check is the simplest code.A named no-op is more than the code needs.

Choose at the boundary. A checkout handler authenticates the shopper, loads the order and supplies configured tax rates; it chooses a recording trace for the “why this price?” view and noTrace for the invoice batch. If a required setting or the rate table is missing, report a configuration failure. Catching a failure and installing the no-op is honest only when the effect is optional by contract and the failure is one you expect and report; a constructor that catches every setup error and quietly installs a no-op hides a broken deployment.

A missing customer may need a create-account path; an empty customer can hide that choice. A default value fills in data, while a null object supplies behavior. A recorder saves evidence; a mock checks expectations. Those tools may share a method shape, but they have different jobs. If a caller keeps asking whether it received noTrace before every call, the boundary is not simplifying the client: reconsider the contract or keep the check.

Its neighbours: Fowler’s Special Case (Patterns of Enterprise Application Architecture, 2002) generalizes it, an object for a case such as an unknown customer that answers with that case’s values, not with nothing. A null object is often the Strategy that does nothing, and the observing wrapper a test puts around a trace is a Decorator. OpenTelemetry’s API without an SDK hands out non-recording spans: recording stops while the trace context still propagates, because the neutral behavior is defined per operation.

When the trace sends data elsewhereDelivery, cost and ownership

A remote trace can be slow or unavailable. Decide whether pricing waits, writes to a bounded queue, or treats explanation as best effort. The adapter owns delivery failure policy and health. A no-op also receives its arguments: formatting the tax line happens before note is called.

The stateless no-op can be shared. A recorder needs a quote-sized or otherwise bounded lifetime; this teaching recorder grows without limit and has no guard against concurrent changes. If every quote’s reasoning must be stored with the order, the trace is no longer optional, and the no-op stops being a valid choice.

06Make the call

Which absence can the code accept?

A checkout can price an order without explaining it, but its tax-rate table is required. During startup the rate table fails to load. What should the application do?
A photo service runs on a laptop for local development, with no cache server and no permissions service. Its code goes through a Cache (a get may miss; a set may be dropped) and an Authorizer (may this user delete this photo?). Which may get a do-nothing implementation locally?

Before you open the answer below, say it in your own words: why is noTrace a valid trace here, why would a rates object that answers 0 be invalid, and what change to the requirements would make a silent explanation unacceptable?

One way to say itWhich effect may disappear

The trace promises to accept explanation lines, not to keep them, so a trace that keeps none fulfils its contract: every quote is the same with it or without it, and pricing never has to ask which one it has. The caller makes that choice once, at the boundary. A tax rate is different: its contract is “the rate for this region, or an error”, and 0 is a rate, so a rates object that answers 0 turns a missing configuration into a wrong total that looks right. That is the line a null object must not cross: it may replace an effect that can disappear without changing what success means, never an answer the caller depends on. And something that only looks like the collaborator, a cast, a nil pointer, an inherited default, is not one: a null object implements the contract. If the product starts requiring every quote’s reasoning to be stored with the order, the explanation is no longer optional, and the no-op stops being a valid choice.

Practice in the lab

A bike-share rider app unlocks a bike at a dock and docks it again. The dock is required: an unlock it refuses must say so. A haptic pulse on the phone confirms each success, and riders may turn it off, just as the lesson’s quote may go unexplained. Later a rider’s pass enters the fare. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • NULL-OBJECT-101 (easy) Riders who turned haptics off still feel the buzz
  • NULL-OBJECT-102 (medium) Riders without a pass unlock for free Pro
  • NULL-OBJECT-103 (hard) Review: “never crash at startup” turns every failure into a no-op Pro