01 / The idea
One caller wants the explanation. Another does not.
An optional callback is a sensible first version. Before noting a step, check whether anyone
is listening. In TypeScript, onLine?.(line) can express that clearly in one line.
The pressure appears when the explanation passes through several hands. The checkout handler gives it to the pricing function, and each step wants to note what it decided: the subtotal, which discount applied, whether shipping was free, which tax rate was used. A support tool then wants the same lines for “why was this customer charged that?”, the invoice batch wants none of them, and a test wants to assert them. Every step now repeats the check, even though none of those places decides whether the caller wants an explanation at all.
Null Object represents an intentionally absent behavior with an object that fulfills the
same collaborator contract. Here, NoTrace accepts explanation lines and retains nothing. The caller chooses
it once. The pricing calls note normally.
The name describes the behavior; the object itself is a real value with a callable method,
so nothing calls a method on null or nil.
If you have given a React context a default object whose methods do nothing, you have used one: a component rendered outside the provider receives that object and calls it like any other.
02 / See the shape
A small interface can carry a deliberate choice.
The Basic form defines an explanation line and a trace with one method. The no-op implementation ignores that line. TypeScript uses an object literal, Go an empty struct with a value receiver. A class hierarchy is optional.
In the wild adds a recording trace and an authored pricing function. It validates the order, the discount code, and the tax region first, then computes five amounts and notes each one as it is decided. A failure stops the quote before any line is noted and returns or throws an error naming what was wrong. The pricing contains no check for the selected trace’s type.
| Participant | Owns | Observable promise |
|---|---|---|
| Caller | Explanation policy and configured dependencies. | Selects silence intentionally and handles the result. |
| Pricing | Validation order and the five amounts. | Returns a quote or a named failure; never a partial total. |
| Rates | The tax percentage for a region. | Answers with a rate or an error, never a guess. |
| Trace | Optional explanation handling. | Accepts a line synchronously without throwing or panicking. |
An explanation contract and an implementation that intentionally retains nothing. TypeScript can use an object literal; Go uses a stateless concrete type.
export type TraceLine = Readonly<{ label: string; cents: number }>;
// Explanations are optional, synchronous, and non-throwing by contract.
export interface QuoteTrace {
note(line: TraceLine): void;
}
export const noTrace: QuoteTrace = Object.freeze({
note() {
// Intentionally retain and deliver nothing.
}
}); type TraceLine struct {
Label string
Cents int
}
// Explanations are optional, synchronous, and non-panicking by contract.
type QuoteTrace interface {
Note(TraceLine)
}
type NoTrace struct{}
func (NoTrace) Note(TraceLine) {
// Intentionally retain and deliver nothing.
} The call site deliberately selects the silent trace and prices two notebooks and four pens with the WELCOME10 code in the north region. The quote comes back with a total of 39.99 and its breakdown. Zero retained lines does not change one cent of it.
The shared contract and its boundariesWhat is required, what is optional, and what the fixture leaves out
Items are already parsed: a name, a whole number of cents, and a quantity. Validation runs in a fixed order: an empty order, then each item’s quantity and price, then the discount code, then the region. The first broken rule names the failure.
The amounts are integer cents. WELCOME10 takes a tenth of the subtotal, rounded down. Shipping is 5.00 unless FREESHIP applies or the discounted subtotal reaches 50.00. Tax applies the region’s whole percentage to the discounted subtotal and rounds half up.
The recording trace copies lines and returns independent snapshots. It accumulates history when reused. The no-op trace keeps no state. Both are synchronous and non-throwing by contract, a promise the type signatures cannot enforce. The pricing does not catch a broken trace and silently replace it.
The implementations copy the item list before validating it, so the same items stay in place during one quote.
Reading the TypeScriptStructural interfaces, an object literal, and a typed error
QuoteTrace describes a method shape. The object literal and the recording
class both match it. An implementation can omit an unused parameter even though callers
pass the line through the interface. Object.freeze prevents replacing the shared
no-op’s method and keeps the returned quote read-only.
QuoteFailure carries the failure code as its message. The caller checks
that error type and handles it; an unrelated exception is rethrown. A return type of void means the caller uses no return value; it says nothing about whether the
method can throw.
Reading the GoA concrete zero value is different from a nil interface
NoTrace{} is a usable value that satisfies QuoteTrace. The recording implementation uses a pointer receiver because it
appends to retained state. PriceOrder returns a quote value and an error; when
the error is set, the quote is the zero value and must not be read as a total.
The pricing expects initialized collaborators. A nil interface is not a no-op trace. An
interface holding a typed nil pointer is also non-nil: var r *RecordingTrace; var t QuoteTrace = r gives a value whose Note panics on r.lines. Construct real adapters at the
boundary.
03 / Follow the effects
Silence should change one column.
Price the notebooks and pens with RecordingTrace. Predict a total and five retained lines: subtotal, discount, shipping, tax, total. Switch to NoTrace and price again. The same five calls occur, the total is the same 43.88, and the trace retains zero lines.
Now choose the island region, which has no rate. In either mode, the quote fails with unknown-region-island and no line is noted. Try the empty order and the zero-quantity notebook too: each names its failure whether or not anyone is listening. Then apply WELCOME10 to the desk lamp order and watch shipping stay free because the discounted subtotal still reaches 50.00.
Same quote. Optional explanation.
Predict whether the quote succeeds and how many explanation lines are retained, then price the order.
Notebook · 12.00 × 2Pen · 3.00 × 4Each run uses fresh rates and a fresh recorder.
Change a setting and predict what may disappear. The total and any failure are required outcomes.
Required result
Quote
The amounts the caller receives.
Waiting for a run.
Optional collaborator
RecordingTrace
Copies each explanation line into its own list.
note(line)↓Append a lineOptional effect
Why this price?
Waiting for a run.
Inspect the calls before the trace handles them
The lab’s observing wrapper records these calls before forwarding each one to the selected trace.
Price an order to collect a trace.
The trace of calls comes from a small observing wrapper in the lab that records each call before forwarding it to the selected trace, so you can see the calls NoTrace deliberately forgets.
04 / Try a decision
Would doing nothing still fulfill the promise?
A no-op method is easy to write. Deciding whether it preserves the meaning of a quoted total is the engineering work.
Reason through the alternatives
Replace the missing tax table with a NullRates whose percentFor always returns 0. Every quote succeeds and every total is wrong: customers see untaxed prices and the order records carry them. Matching the interface does not make zero a valid rate.
Reject the missing tax table at setup; use NoTrace only when the explanation is explicitly optional. The required dependency is checked before any order is priced. A silent explanation is a deliberate policy, while a missing rate table remains a configuration error. A quoted total still means the rates were applied.
Catch the rate lookup error and continue with tracing switched off so the quote can finish. The trace was never the problem. Pricing cannot finish without a rate, so continuing would mean inventing one, which is the same false success in a different place. The failure has to reach the caller as the result.
A no-op belongs only where doing nothing fulfills the contract.
Why is NoTrace a valid trace here? Why would a NullRates be invalid? Name one change to the product requirements that would make a silent explanation unacceptable.
This note stays on this page; nothing saves or grades it.05 / Give it a real job
Choose the collaborator when the request is assembled.
A checkout handler authenticates the shopper, loads the order, and supplies configured tax rates. It chooses a recording trace when the page will show “why this price?”, or NoTrace for the invoice batch. The pricing computes the amounts and returns its quote. The order record, the receipt, and any required audit of pricing decisions have their own persistence and failure policy.
Keep “explicitly disabled” distinct from “configuration missing.” If the application requires a setting for the explanation panel, validate it before assembling the request. A constructor that catches every setup error and returns NoTrace can hide a broken deployment.
A future support tool becomes another trace. The pricing’s five steps can stay the same. If the product instead starts requiring every quote’s reasoning to be stored with the order, revise the contract: an in-memory recorder and a no-op may both become insufficient.
Failure, lifetime, and costs that a no-op does not removeRemote delivery, retained state, and eager arguments
A trace that ships lines to an analytics service can be slow or unavailable. Decide whether pricing awaits delivery, writes to a bounded queue, or treats explanation as best effort. Handle delivery failures in that adapter according to a documented policy, and expose its health separately.
A no-op still receives its arguments. The label tax north 8% is formatted
before note is called, whichever trace is selected. A lazy payload function or
an explicit capability check can be useful when suppressing expensive construction is itself
a requirement.
The stateless no-op can be shared. A recorder belongs to one quote or another clearly bounded scope; reusing it accumulates lines across calls. The demonstration recorder grows without a limit and is not safe for concurrent mutation. Real adapters also need owners for subscriptions, queues, and cleanup.
Build UIs?A context default answers for a missing provider without a word. Reporting a failed save makes that your decision.
Where it already is in your components
If you have written a hook that throws when its provider is missing, you already follow a
rule that comes from this pattern. React’s createContext(defaultValue) takes the value a component receives when there is no matching provider above it, and the useContext docs suggest a meaningful default so that a component rendered without its provider “won’t break.”
Give a context a default object whose methods do nothing, and React hands that Null Object to
every component outside the provider: a test with no setup, and a tree where someone forgot
the provider. In our React 19.1.0 run, a component outside the provider called the default’s
method, and the console stayed empty.
That silence is right when doing nothing fulfills the contract, and wrong when the value
is required. So a required context gets null as its default, which the docs
suggest when there is no meaningful default, and a hook that throws when it reads one.
Check for undefined too: the default applies only when there is no provider
at all, and in our run a provider rendered without value passed undefined and logged “The value prop is required for the <Context.Provider>. Did you misspell it or forget to pass it?” Svelte
leaves the choice to you as well. In Svelte 5.57, getContext returned undefined without a warning when no parent had set the key, and the
getter from createContext threw “Context
was not set in the current component or any of its ancestors.”
When you have to own it
Now your app sends caught errors to a monitoring service. A settings form’s Save button reports a failed request, and the same button renders in production, in component tests, and in stories, where nothing should be sent. A reporter that does nothing is a fair Null Object here: the person still sees that the save failed, and only the report disappears. The decision is who selects it.
import { createContext, use, useState } from 'react';
export type ErrorReporter = { capture(error: unknown): void };
// Tests and stories pass this on purpose: it keeps nothing and sends nothing.
export const silentReporter: ErrorReporter = Object.freeze({ capture() {} });
// No default reporter, so a tree without a provider fails instead of going quiet.
export const ReporterContext = createContext<ErrorReporter | null>(null);
export function useReporter(): ErrorReporter {
const reporter = use(ReporterContext);
// A provider without a value passes undefined, so check for both.
if (!reporter) throw new Error('useReporter needs a <ReporterContext value={…}> above it');
return reporter;
}
export function SaveButton({ save }: { save: () => Promise<void> }) {
const reporter = useReporter();
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
async function handleClick() {
setStatus('saving');
try {
await save();
setStatus('saved');
} catch (error) {
reporter.capture(error);
setStatus('failed');
}
}
return (
<>
<button type="button" disabled={status === 'saving'} onClick={handleClick}>
Save
</button>
{status === 'saved' && <p role="status">Saved</p>}
{status === 'failed' && <p role="alert">Couldn’t save. Try again.</p>}
</>
);
}
The context has no default, so a Save button rendered without a reporter fails at once
instead of quietly dropping every report in production. Tests and stories pass silentReporter on purpose, and the app’s root builds the real reporter from its
configuration. Keep the checkout handler’s distinction here too: a production build missing
its monitoring key should fail at startup rather than fall back to silence.
The button renders success or failure from the save itself, never from the reporter. In React 19.1.0 under Strict Mode, a rejected save showed “Couldn’t save. Try again.” with the silent reporter and with a recording one, and only the recording one captured the error, once per click.
06 / Already in your toolbox
Look for a real value with a neutral effect.
Each of these public APIs supplies a real value whose documented effect is neutral.
Go: io.Discard
io.Discard implements Writer, and writes succeed while discarding their data. A consumer can use the usual writing interface when output is intentionally unwanted.
Read the io.Discard contract ↗OpenTelemetry: non-recording spans
Without an installed SDK, the Trace API generally performs no recording work. It still preserves a parent SpanContext when returning a non-recording span. That exception shows why “do nothing” must be defined operation by operation: recording may disappear while context propagation remains required. Our explanation trace is a small cousin of this design.
Read the no-SDK behavior ↗07 / Make the choice
Optional behavior and missing information need different designs.
Use a Null Object when there is a well-defined neutral behavior, the caller intentionally selects it, and clients can keep using the ordinary interface without losing information they need. An optional explanation of a computed result is a good fit.
Keep null, Option, or a tagged result when absence changes the caller’s decision. “No customer found” may need a create-account path; a fabricated empty customer can obscure that decision. A deny-all authorization policy may be a useful policy object, but it is still making an explicit decision whose meaning must be defined.
A default value fills in data; a Null Object supplies behavior. A recording test double saves evidence; this no-op deliberately does not. A mock can verify expected calls; this no-op has no expectations. Those tools can satisfy the same method shape while serving different purposes.
If the trace is used in one place and the only change is replacing one readable guard with a new interface, keep the guard. If a client keeps asking whether it received NoTrace before every call, reconsider the contract or the abstraction. The useful simplification is a shared, valid behavior boundary.
08 / Take the idea with you
Name the effect that is allowed to disappear.
Explain this quote without saying “Null Object”: the caller chooses whether the explanation is retained; the pricing always applies real rates and returns an honest total or a named failure. Then apply that explanation to a preview renderer, optional sound feedback, or a test output stream. What does success promise in each case?
Connections to follow nextRelated lessons
Strategy helps compare interchangeable policies; the special policy here is neutral behavior. Decorator explains the lab’s observing wrapper, which adds a call trace while forwarding to the selected trace. Kinds and sentinels explores how explicit values communicate states that callers must distinguish.