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.
// 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.
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}`); | Owner | Decision | Promise |
|---|---|---|
| Caller | Whether the explanation is kept. | Handles the quote or its failure. |
| Pricing | Validation order and the five amounts. | Returns a complete quote or a named pricing failure. |
| Rates | The configured percentage for a known region. | Never substitutes zero for an unknown region. |
| Trace | Whether 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.
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
const silent = priceOrder(
items, 'WELCOME10', 'north', rates, {} as QuoteTrace); 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.
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.
| The change | An optional trace, checked at each step | A null object chosen once |
|---|---|---|
| A sixth amount, a gift-card credit, is noted | Another check whether a trace exists. | Nothing: the new step calls note like the others. |
| Shipping moves into its own function, which notes | The 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 order | An optional trace is now the wrong type. | Doing nothing no longer keeps the contract: remove the no-op. |
| The tax-rate table fails to load | A failure, reported to the caller. | Still a failure: a rates object answering 0 would undercharge. |
| One caller only ever notes one line | One 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?
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