Null Object: replacing nulls with a safe default
Open in the interactive app: practice, quiz, mock interview →In one minute
- A Null Object is a real implementation of an interface whose behaviour is "do nothing, neutrally": a notifier that sends nothing, a discount of zero, a metrics sink that drops counts. Callers use it exactly like any other implementation, so they stop checking for
None. - The
if x is not Nonechecks move from every call site to one place, usually the constructor or factory, where absence is turned into an object once. - The neutral behaviour is an identity element: adding a zero discount, sending to an empty notifier list, or writing to
io.Discardleaves the outcome unchanged. If no such neutral answer exists, the pattern does not apply. - It is the wrong tool when absence is an error. A missing payment gateway must fail loudly; a missing analytics sink may quietly do nothing. Decide which dependencies are optional before you hide them.
- Agents love
if self.metrics: self.metrics.incr(...)scattered through business code. Review asks for a null implementation injected at construction, and for no null objects on dependencies whose absence should stop the program.
How it works
The problem
An order service has three collaborators. Pricing rules may give a discount, a notifier emails the customer, and a metrics client counts orders. In tests, in a CLI tool, and in a small region that has no email provider, some of them are absent, so the code fills with guards:
def place(self, order):
discount = self.discounts.get(order.code)
off = discount.apply(order.subtotal) if discount else 0
total = order.subtotal - off
if self.notifier is not None:
self.notifier.send(order.customer, f"total {total}")
if self.metrics:
self.metrics.incr("orders")
return totalEvery new method repeats the guards, and the one that forgets crashes with AttributeError: 'NoneType' object has no attribute 'send' at the worst moment. Tony Hoare called null references his billion-dollar mistake for this reason: the type says "a notifier" but the value may be nothing, and the compiler cannot tell which call sites remembered.
Replace absence with an object
Write an implementation for the "nothing" case and inject it once:
class SilentNotifier:
def send(self, to, text):
pass
class Checkout:
def __init__(self, notifier=None, metrics=None):
self.notifier = notifier or SilentNotifier()
self.metrics = metrics or NullMetrics()The method body loses its branches: it always calls self.notifier.send and self.metrics.incr. The only null check left is in the constructor, where the decision "no notifier means silence" is stated once and can be read and tested.
Neutral, not empty
A null object must return values that leave the computation unchanged, and the right neutral value depends on the operation. A discount returns 0. A list-returning repository returns an empty list, not None. A feature flag client with no backend returns each flag's coded default. A permission checker has no neutral answer: "allow" is dangerous and "deny" breaks every request, so a null permission checker is a bug waiting to happen. When you cannot name the neutral answer, absence is an error and should be treated as one.
The algebra helps. If you combine implementations (a composite notifier that fans out to email and SMS, a chain of discount rules), the null object is the identity element of that combination: a composite of zero notifiers behaves like the silent notifier, and the sum of zero discounts is zero. That is a quick test of whether your null object is correct.
Where you already use it
Go's io.Discard is a writer that accepts everything. slog.DiscardHandler (Go 1.24) is a handler whose Enabled returns false so loggers skip formatting entirely. Python libraries are told to attach logging.NullHandler() to their top logger so they never print on behalf of an application that has not configured logging. contextlib.nullcontext() is a context manager that does nothing, so with lock or nullcontext(): needs no branch. OpenTelemetry's API returns a no-op tracer when no SDK is installed, which is why libraries can instrument themselves without forcing tracing on their users. Java's Collections.emptyList() is a shared immutable null list.
Special Case and default values
Martin Fowler generalises the idea as Special Case: an object for a recurring edge case, such as an UnknownCustomer with no billing plan or a GuestUser with an empty cart. The same shape appears in system design: a recommendation widget whose service times out shows a fixed "popular this week" list instead of an error. That fallback is a null object for a degraded dependency, the partial failure problem shape handled by substituting a safe neutral answer.
When not to use it
- Required dependencies. A checkout without a payment gateway must not "succeed" silently. Fail at startup.
- When callers need to know. If the caller must render "no discount applied" differently from "0 percent discount", expose that difference (an
is_active()method or an optional type) instead of hiding it. - Languages with good optional types. Kotlin's
?., Rust'sOption, and TypeScript's strict null checks make absence visible to the compiler. Use them for values; keep null objects for collaborators you call methods on.
Reviewing agent-written code
Asked to "make metrics optional", an agent will add if self.metrics: before each of the 14 increments, often missing one. Ask instead for a NullMetrics class, a default in the constructor, and a test that runs the main flow with no optional dependencies. Also check the reverse mistake: an agent that "fixes" a crash by defaulting the payment client to a no-op has turned an outage into silent revenue loss.
Down the stack
Layer 1The contract
A null object promises full substitutability (Liskov): every method of the interface exists, accepts the same inputs, never raises because it is the null variant, and returns the neutral value for that operation. It is stateless, or its state is never observable, so one instance can be shared by every caller and every thread. Its identity is not part of the contract: callers must not write if notifier is SILENT, because that reintroduces the branch the pattern removed. What it does not promise is that absence was intended; that decision belongs to whoever wires the object in.
Layer 2The mechanism
In Python, a null object is a class with empty methods and a module-level instance. In TypeScript, a const object literal satisfying the interface is enough, or a class with a single exported instance. In Go, the idiomatic form is an empty struct type with value receivers, exposed as a package variable of the interface type: var Discard Writer = discard{}. Go's own io.Discard goes a step further and implements ReaderFrom, so io.Copy(io.Discard, r) reads into a pooled 8 KB buffer instead of allocating per call. slog.DiscardHandler shows the other refinement: a null object with a cheap guard method (Enabled returns false) lets the caller skip building arguments at all, which matters more than the cost of the call.
Go has a trap here. An interface value is a pair (type, data). A nil *EmailNotifier stored in a Notifier interface is not equal to nil, because the type half is set, so if n != nil passes and the method call dereferences nil. Returning a null object instead of a typed nil pointer removes the trap.
Layer 3The machine
Allocation: a stateless null object costs nothing per use. In Go, zero-size values such as struct{} share the runtime's zerobase address, and storing one in an interface does not allocate. In Python and TypeScript, share one module-level instance rather than building one per request.
Dispatch: the null object replaces a predictable branch with an indirect call. A nil check is under 1 ns when predicted. A Go interface call loads the itab and the function pointer: about 1 to 2 ns, and it blocks inlining unless profile-guided optimisation devirtualises the site. In V8, a call site that sees both EmailNotifier and SilentNotifier becomes polymorphic, which the inline cache handles cheaply up to four shapes. In CPython, calling an empty method costs roughly 30 to 60 ns against about 15 ns for if x is not None. Next to one network call, all of this is noise.
Concurrency: a stateless null object needs no lock. One that counts calls "for debugging" is no longer null; it needs atomics and becomes a contention point on hot paths.
Why it's built this way
- Optional collaborators are common (tests, CLIs, small deployments), and checking for absence at every call site is repetitive and easy to forget.
- Polymorphism already answers "which behaviour runs here", so absence can be one more implementation instead of a special branch.
- The behaviour must not change results, so the null implementation returns the identity value of each operation.
- Having no state makes it free to share, which removes allocation and locking costs.
- Silencing a required dependency hides outages, so the decision is made once, explicitly, at construction.
Diagram
classDiagram
class Notifier {
<<interface>>
+send(to, text)
}
class EmailNotifier {
+send(to, text)
}
class SilentNotifier {
+send(to, text)
}
class Checkout {
-notifier: Notifier
-metrics: Metrics
+place(customer, subtotal, code)
}
Notifier <|.. EmailNotifier
Notifier <|.. SilentNotifier
Checkout --> NotifierWorked example
A marketplace's order service ran in 4 regions, one without an SMS provider, and an admin CLI reused it with neither SMS nor metrics. The code had 37 if self.sms and if self.metrics checks across 11 files. In one quarter, a refund path added without the guard crashed every refund in the SMS-less region for 40 minutes, and the CLI crashed on a metrics call in a batch job.
The refactor added SilentSms and NullMetrics, defaulted them in the composition root, and deleted all 37 checks. A test now builds the service with only its required dependencies (database, payment gateway) and runs every public method. A benchmark showed the extra interface calls add under 200 ns to a request that spends 35 ms in the database and payment provider.
The same review caught the opposite mistake: a NullPaymentGateway "so the CLI can start without credentials". It was rejected in favour of a --dry-run flag that wires an explicit DryRunGateway which logs what it would have charged, and production startup fails without the real gateway.
Trade-offs
| Option | Strengths | Weaknesses | Choose when | |
|---|---|---|---|---|
| Null checks at call sites | Absence is visible where it matters | Repetitive; one missed check crashes | Very few call sites, or the caller must react to absence | |
| Null Object | No branches; shared and free; tests run with minimal setup | Hides absence; wrong for required dependencies | Optional collaborators with a clear neutral behaviour | |
Optional type (Option, `T \ | undefined`) | Compiler forces handling | Handling still spread over callers | Values that may be absent, such as lookups |
| Special Case object | Encodes domain defaults (guest user, unknown customer) | More types to maintain | A recurring edge case with domain meaning | |
| Fail fast at startup | Misconfiguration found before traffic | Less flexible for tests and tools | Required dependencies |
Pitfalls in production
- A null object wired for a required dependency, so failures become silent data loss (orders without receipts, audits not written). Fix: list which dependencies may be null and fail at startup for the rest.
- A Go function returning a typed nil pointer as an interface, so
!= nilchecks pass and the call panics. Fix: return the null object or an untypednil. - Production accidentally running on the null default because a config key was misspelled, so metrics read zero and nobody notices. Fix: log which implementation was wired at startup and alert on a metrics series going absent.
- Identity checks against the null instance (
if notifier is SILENT) creeping back into business code. Fix: add a method to the interface if callers truly need to know. - Null objects that accumulate state, such as a "null" cache that stores entries, which then leaks memory. Fix: keep them stateless; a test-recording fake is a different class.
Leadership lens
- Ask in design review: "Which dependencies are optional, and what does the system do without each one?" The answer decides where null objects are allowed and where startup must fail.
- Null objects make the minimal configuration testable. Require a test that boots the service with only required dependencies; it catches every forgotten guard at once.
- Treat a null default in production as a configuration risk. Startup logs and a dashboard of which implementations are active make silent degradation visible.
- When agents generate guards, point them at the house rule: optional collaborators get a null implementation and a constructor default, never call-site checks.
Check yourself
Why is a null permission checker usually a mistake, while a null metrics sink is fine?
Show answer
Metrics have a neutral behaviour (record nothing) that does not change results. Authorization has none: allowing everything is a security hole and denying everything breaks the service, so absence must be an error.
How does the identity-element view help you check a null object's return values?
Show answer
Combining it with any real implementation must give the same result as the real one alone, for example adding a zero discount or fanning out to an empty notifier list. If a return value would change the result, it is not neutral.
Why does returning a typed nil pointer as an interface in Go defeat
if n != nil?Show answer
The interface holds a non-nil type and a nil data pointer, so it compares unequal to nil, and calling a method that dereferences the receiver panics.
When would you prefer an optional type over a null object?
Show answer
When the caller must behave differently if the value is absent, such as showing "not found", or when the value is data rather than a collaborator you call methods on.
Watch
- Nothing is Something (Sandi Metz, RailsConf 2015): the classic talk on replacing conditionals about absence with objects.
- Design Patterns: Null Object (Ludonaut): a short walk-through of removing null checks with a null implementation.
Free resources
- Special Case (Martin Fowler): the generalisation of null objects to any recurring edge case.
- Introduce Null Object (Refactoring.Guru): the refactoring steps from scattered checks to a null class.
- Null References: The Billion Dollar Mistake (Tony Hoare, InfoQ): the history of null and why it causes so many failures.
- Configuring logging for a library (Python docs): why libraries attach a
NullHandler. - slog.DiscardHandler (Go docs): a null handler whose
Enabledcheck lets callers skip work entirely. - Why is my nil error value not equal to nil? (Go FAQ): the typed-nil interface trap that null objects avoid.