Design Patterns (GoF and modern) Module 6 – Modern patterns for application code
Null Object, Special Case and Option types
Stop repeating checks for a missing value: a do-nothing Null Object, a Special Case with its own rules, or an Option type that makes each caller decide.
What you will learn
- Replace repeated null checks with a Null Object that has safe default behaviour
- Model domain special cases such as an unknown or guest customer
- Choose between a Null Object and an Option or Optional type for missing values
- Avoid Null Objects that hide real configuration errors
Before you start
What you’ll learn · Before you start
What you will learn
- Replace repeated null checks with a Null Object that has safe default behaviour
- Model domain special cases such as an unknown or guest customer
- Choose between a Null Object and an Option or Optional type for missing values
- Avoid Null Objects that hide real configuration errors
Before you start
On this page
A checkout that asks “is there a metrics client?” before each count, and pricing code that asks “is there a
customer?” before every discount, pay for one missing value again and again. A Null Object: An object that implements the interface its callers expect and does nothing harmful, such as a NoOpMetrics that ignores every count, so callers need not check whether a collaborator is there.
implements the interface its callers expect and does nothing harmful, so code can call it without checking: a
NoOpMetrics that ignores every count. A Special Case: An object that stands for one known situation, such as a guest checkout, and answers the questions callers ask with the rules of that situation instead of returning nothing. does the same in the domain: a
GuestCustomer stands for a shopper who checks out without signing up, and answers every question the pricing code
asks with the rules for guests.
option type: A type that holds either a value or nothing, such as Optional<T> in Java or Option<T> in Rust, so that each caller has to decide what an empty result means. go the other way and put absence into the type: Optional<T> in Java,
Option<T> in Rust, T | undefined in TypeScript and T | None in Python make each caller decide what “nothing
found” means. Use a Null Object when absence should mean “do nothing”, a Special Case for a known case with its own
rules, and an Option type when only the caller can decide.
Why checks for a missing value spread
Tony Hoare, who introduced the null reference in ALGOL W, later called it his “billion-dollar mistake”, and ordinary
code shows why. When a lookup can return None (or null, or nil), every use of the result has to remember to
check, and each check repeats a decision about what a missing customer means. In the program below one account is on
file and one is not, and the result of the same lookup is used twice. Step through both accounts:
Program · spread/forgotten_check.py
TIERS = {"c-101": "gold"}
for account in ["c-101", "c-404"]:
tier = TIERS.get(account)
if tier is None:
print(account, "0% off")
else:
print(account, "10% off")
print("badge:", tier.title()) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
Printed so far
Nothing printed yetc-101 10% off badge: Gold c-404 0% off Error AttributeError: 'NoneType' object has no attribute 'title'
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 12 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
TIERS = {"c-101": "gold"}makesTIERSa dict with 1 key. - Line 3.
for account in ["c-101", "c-404"]:takes the item at position 0 of["c-101", "c-404"]:accountis'c-101'. - Line 4.
tier = TIERS.get(account)setstierto'gold'. - Line 5.
tier is Noneis false (tieris'gold'), so the else block runs. - Line 8.
print(account, "10% off")printsc-101 10% off. - Line 9.
print("badge:", tier.title())printsbadge: Gold. - Line 3.
for account in ["c-101", "c-404"]:takes the item at position 1 of["c-101", "c-404"]:accountis'c-404'. - Line 4.
tier = TIERS.get(account)setstiertoNone(it was'gold'). Key pointc-404is not on file, soget()returnsNone.Nonesays only that the tier is missing, not why: a guest and a deleted account would look exactly the same here. - Line 5.
tier is Noneis true (tierisNone), so the block underif tier is None:runs. - Line 6.
print(account, "0% off")printsc-404 0% off. Key point This use remembered the check, and with it made a decision about what a missing customer means: no discount. Every other use oftierhas to make that decision again. - Line 9.
print("badge:", tier.title())stops the program withAttributeError: 'NoneType' object has no attribute 'title'. The error is reported on line 9, whereNonewas used, five lines after line 4, where it came from. In a real program the two are often in different files.
The discount remembered its check and decided that a missing customer gets 0% off. The badge forgot, and the program
stopped with an AttributeError on line 9, five lines from the lookup that produced the None; in Java the same slip
is a NullPointerException. The checks that are remembered have a cost too, because they crowd out the business
rules they surround. And None carries no reason: a guest and an account that was deleted look the same, so both end
up under the same rules.
Four answers to a missing value
The useful question is not how to avoid null, but what this particular absence should mean. Each answer leads to its own design, and the questions below lead to it:
Four answers to a missing value
Text description of the diagram
The diagram is a column of three questions about something the code needs and does not have. Each "no" leads to the next question.
- Should the missing part do nothing? If yes, use a Null Object, such as NoOpMetrics.
- Is it a known case with its own rules? If yes, use a Special Case, such as GuestCustomer.
- Must each caller decide what it means? If yes, use an Option type, such as Optional in Java or Option in Rust.
- If every answer is no, the missing value is a mistake in set-up or data: fail fast by raising an error or refusing to start.
- A Null Object accepts every call and does nothing, so callers stop checking.
- A Special Case answers like a real object, with the rules of one known situation.
- Option types make absence part of a function’s return type, so every caller must handle it.
- fail fast: To raise an error as soon as a mistake is detected, ideally at start-up, instead of carrying on with a missing or invalid value that would do harm later. means raising an error at once, ideally at start-up, because the absence is a mistake.
| Null Object | Special Case | Option type | Fail fast | |
|---|---|---|---|---|
| What the caller gets | An object that does nothing | An object with domain rules | A value that may be empty | An error |
| Who decides what absence means | The object, once | The domain model, once | Each caller | Nobody: it must not happen |
| Typical examples | Metrics, audit logs, a cart’s missing discount code | A guest, an unknown account | A lookup by ID, a parsed number | A missing payment key or database address |
| The risk when misused | Hides a part that was required | Rules spread into many small classes | Callers unwrap without looking | Stops the program for an ordinary case |
Option types and the other three are not rivals: an application usually needs all four, chosen one missing value at a time.
Checkpoint
Check: Four answers to a missing value · Why checks for a missing value spread
A Null Object in code
The checkout below asks its metrics collaborator to count items and completed checkouts, and never asks whether one
exists. It depends on a Metrics interface with one method, and two objects implement it: the Null Object, which
ignores every call, and a recorder that a test can read. Neither is known to the checkout:
The checkout, its Metrics interface and the two objects that implement it
Text description of the diagram
The diagram shows four parts in one column, joined by arrows from top to bottom.
- The composition root chooses the metrics object once and passes it to the checkout.
- The checkout calls metrics.increment() and never checks for None.
- Metrics is the interface, with one method, increment(name, by). A dashed arrow says it is implemented by the two objects below it.
- Side by side: NoOpMetrics, the Null Object, which ignores every call, and RecordingMetrics, which adds up each count for a test to read.
The checkout has no arrow to either object: it knows only the interface.
The program runs the checkout once with nothing configured and once with the recorder, as a test would, in 14 languages whose outputs match line for line:
Python 3.14 · main.py
from typing import Protocol
class Metrics(Protocol):
def increment(self, name: str, by: int) -> None: ...
class NoOpMetrics:
"""The Null Object: every call is accepted and ignored."""
def increment(self, name: str, by: int) -> None:
pass
class RecordingMetrics:
def __init__(self) -> None:
self.counts: dict[str, int] = {}
def increment(self, name: str, by: int) -> None:
self.counts[name] = self.counts.get(name, 0) + by
NO_METRICS = NoOpMetrics() # no state, so one shared instance serves every checkout
class Checkout:
def __init__(self, metrics: Metrics = NO_METRICS) -> None:
self._metrics = metrics # never None, so nothing below checks
def total(self, prices_paise: list[int]) -> int:
self._metrics.increment("checkout.items", len(prices_paise))
self._metrics.increment("checkout.completed", 1)
return sum(prices_paise)
cart = [49_900, 12_500, 2_000]
print(f"no metrics configured: {Checkout().total(cart)} paise")
recorded = RecordingMetrics()
print(f"metrics recorded: {Checkout(recorded).total(cart)} paise")
for name, count in sorted(recorded.counts.items()):
print(f" {name} = {count}") In Python: The constructor's default is the shared NO_METRICS object rather than None, so self._metrics is never missing. Metrics is a typing.Protocol, which NoOpMetrics satisfies without inheriting from it.
Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py
JavaScript Node 24 · main.mjs
/** @typedef {{ increment(name: string, by: number): void }} Metrics */
/** The Null Object: every call is accepted and ignored. */
class NoOpMetrics {
increment() {}
}
class RecordingMetrics {
counts = new Map();
increment(name, by) {
this.counts.set(name, (this.counts.get(name) ?? 0) + by);
}
}
const NO_METRICS = new NoOpMetrics(); // no state, so one shared instance serves every checkout
class Checkout {
#metrics;
/** @param {Metrics} metrics */
constructor(metrics = NO_METRICS) {
this.#metrics = metrics; // never undefined, so nothing below checks
}
total(pricesPaise) {
this.#metrics.increment("checkout.items", pricesPaise.length);
this.#metrics.increment("checkout.completed", 1);
return pricesPaise.reduce((sum, price) => sum + price, 0);
}
}
const cart = [49_900, 12_500, 2_000];
console.log(`no metrics configured: ${new Checkout().total(cart)} paise`);
const recorded = new RecordingMetrics();
console.log(`metrics recorded: ${new Checkout(recorded).total(cart)} paise`);
for (const [name, count] of [...recorded.counts].sort(([a], [b]) => (a < b ? -1 : 1))) {
console.log(` ${name} = ${count}`);
} In JavaScript: The default parameter metrics = NO_METRICS puts the Null Object in place when the caller passes nothing, and NoOpMetrics.increment() declares no parameters, since it ignores them.
Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs
Java 25 LTS · Main.java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class Main {
interface Metrics {
void increment(String name, int by);
}
/** The Null Object: every call is accepted and ignored. */
static final class NoOpMetrics implements Metrics {
@Override
public void increment(String name, int by) {}
}
static final class RecordingMetrics implements Metrics {
final Map<String, Integer> counts = new TreeMap<>(); // kept sorted by name
@Override
public void increment(String name, int by) {
counts.merge(name, by, Integer::sum);
}
}
static final Metrics NO_METRICS = new NoOpMetrics(); // no state: one instance serves all
static final class Checkout {
private final Metrics metrics; // never null, so nothing below checks
Checkout() {
this(NO_METRICS);
}
Checkout(Metrics metrics) {
this.metrics = metrics;
}
int total(List<Integer> pricesPaise) {
metrics.increment("checkout.items", pricesPaise.size());
metrics.increment("checkout.completed", 1);
return pricesPaise.stream().mapToInt(Integer::intValue).sum();
}
}
public static void main(String[] args) {
var cart = List.of(49_900, 12_500, 2_000);
System.out.println("no metrics configured: " + new Checkout().total(cart) + " paise");
var recorded = new RecordingMetrics();
System.out.println("metrics recorded: " + new Checkout(recorded).total(cart) + " paise");
recorded.counts.forEach((name, count) -> System.out.println(" " + name + " = " + count));
}
} In Java: Java has no default arguments, so the constructor without parameters passes NO_METRICS to the other one. A TreeMap keeps the recorded names sorted.
Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java
C++23 · main.cpp
#include <map>
#include <numeric>
#include <print>
#include <string>
#include <vector>
struct Metrics {
virtual ~Metrics() = default;
virtual void increment(const std::string &name, int by) = 0;
};
// The Null Object: every call is accepted and ignored.
struct NoOpMetrics : Metrics {
void increment(const std::string &, int) override {}
};
struct RecordingMetrics : Metrics {
std::map<std::string, int> counts; // kept sorted by name
void increment(const std::string &name, int by) override {
counts[name] += by;
}
};
NoOpMetrics
no_metrics; // no state, so one shared instance serves every checkout
class Checkout {
public:
explicit Checkout(Metrics &metrics = no_metrics) : metrics_(metrics) {}
int total(const std::vector<int> &prices_paise) {
metrics_.increment("checkout.items", static_cast<int>(prices_paise.size()));
metrics_.increment("checkout.completed", 1);
return std::accumulate(prices_paise.begin(), prices_paise.end(), 0);
}
private:
Metrics &metrics_; // a reference is never null, so nothing below checks
};
int main() {
const std::vector<int> cart = {49'900, 12'500, 2'000};
std::println("no metrics configured: {} paise", Checkout().total(cart));
RecordingMetrics recorded;
std::println("metrics recorded: {} paise", Checkout(recorded).total(cart));
for (const auto &[name, count] : recorded.counts) {
std::println(" {} = {}", name, count);
}
} In C++: The checkout holds a reference, which cannot be null, and the default argument binds it to the shared no_metrics object.
Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main
C# 14 · .NET 10 · app.cs
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
int[] cart = [49_900, 12_500, 2_000];
Console.WriteLine($"no metrics configured: {new Checkout().Total(cart)} paise");
var recorded = new RecordingMetrics();
Console.WriteLine($"metrics recorded: {new Checkout(recorded).Total(cart)} paise");
foreach (var (name, count) in recorded.Counts)
{
Console.WriteLine($" {name} = {count}");
}
interface IMetrics
{
void Increment(string name, int by);
}
/// <summary>The Null Object: every call is accepted and ignored.</summary>
sealed class NoOpMetrics : IMetrics
{
public static readonly NoOpMetrics Instance = new(); // no state: one instance serves all
public void Increment(string name, int by) { }
}
sealed class RecordingMetrics : IMetrics
{
public SortedDictionary<string, int> Counts { get; } = new(StringComparer.Ordinal);
public void Increment(string name, int by) =>
Counts[name] = Counts.GetValueOrDefault(name) + by;
}
sealed class Checkout(IMetrics? metrics = null)
{
readonly IMetrics metrics = metrics ?? NoOpMetrics.Instance; // never null after this line
public int Total(int[] pricesPaise)
{
metrics.Increment("checkout.items", pricesPaise.Length);
metrics.Increment("checkout.completed", 1);
return pricesPaise.Sum();
}
} In C#: The primary constructor's parameter is nullable, and ?? swaps in NoOpMetrics.Instance once, so the field is never null afterwards. A SortedDictionary keeps the names in order.
Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs
C23 · main.c
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct Metrics {
void (*increment)(struct Metrics *self, const char *name, int by);
} Metrics;
// The Null Object: every call is accepted and ignored.
static void ignore(Metrics *self, const char *name, int by) {
(void)self;
(void)name;
(void)by;
}
static Metrics no_metrics = {.increment = ignore}; // no state: one serves all
typedef struct {
const char *name;
int count;
} Count;
typedef struct {
Metrics metrics; // first, so a RecordingMetrics * is also a Metrics *
Count counts[8];
size_t used;
} RecordingMetrics;
static void record(Metrics *self, const char *name, int by) {
RecordingMetrics *r = (RecordingMetrics *)self;
size_t i = 0;
while (i < r->used && strcmp(r->counts[i].name, name) != 0) {
i++;
}
if (i == r->used) {
r->counts[r->used++] = (Count){.name = name, .count = 0};
}
r->counts[i].count += by;
}
static int by_name(const void *a, const void *b) {
return strcmp(((const Count *)a)->name, ((const Count *)b)->name);
}
// Checkout takes any Metrics, and the caller passes &no_metrics rather than
// NULL.
static int total(Metrics *metrics, const int *prices_paise, size_t n) {
metrics->increment(metrics, "checkout.items", (int)n);
metrics->increment(metrics, "checkout.completed", 1);
int sum = 0;
for (size_t i = 0; i < n; i++) {
sum += prices_paise[i];
}
return sum;
}
int main(void) {
const int cart[] = {49900, 12500, 2000};
printf("no metrics configured: %d paise\n", total(&no_metrics, cart, 3));
RecordingMetrics recorded = {.metrics = {.increment = record}, .used = 0};
printf("metrics recorded: %d paise\n", total(&recorded.metrics, cart, 3));
qsort(recorded.counts, recorded.used, sizeof recorded.counts[0], by_name);
for (size_t i = 0; i < recorded.used; i++) {
printf(" %s = %d\n", recorded.counts[i].name, recorded.counts[i].count);
}
return 0;
} In C: The interface is a struct with one function pointer, and the Null Object's function ignores its arguments; callers pass &no_metrics, never NULL. C has no map, so the recorder keeps an array that qsort orders.
Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main
PHP 8.5 · main.php
<?php
declare(strict_types=1);
interface Metrics
{
public function increment(string $name, int $by): void;
}
/** The Null Object: every call is accepted and ignored. */
final class NoOpMetrics implements Metrics
{
public function increment(string $name, int $by): void {}
}
final class RecordingMetrics implements Metrics
{
/** @var array<string, int> */
public array $counts = [];
public function increment(string $name, int $by): void
{
$this->counts[$name] = ($this->counts[$name] ?? 0) + $by;
}
}
final class Checkout
{
private readonly Metrics $metrics; // never null, so nothing below checks
public function __construct(?Metrics $metrics = null)
{
static $noMetrics = new NoOpMetrics(); // no state: one shared instance serves all
$this->metrics = $metrics ?? $noMetrics;
}
/** @param list<int> $pricesPaise */
public function total(array $pricesPaise): int
{
$this->metrics->increment('checkout.items', count($pricesPaise));
$this->metrics->increment('checkout.completed', 1);
return array_sum($pricesPaise);
}
}
$cart = [49_900, 12_500, 2_000];
echo 'no metrics configured: ' . (new Checkout())->total($cart) . " paise\n";
$recorded = new RecordingMetrics();
echo 'metrics recorded: ' . (new Checkout($recorded))->total($cart) . " paise\n";
ksort($recorded->counts, SORT_STRING);
foreach ($recorded->counts as $name => $count) {
echo " {$name} = {$count}\n";
} In PHP: PHP has no constant objects, so a static variable holds the one NoOpMetrics, and ?? puts it in place when the argument is null.
Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php
Rust 1.99 · main.rs
use std::collections::BTreeMap;
trait Metrics {
fn increment(&mut self, name: &'static str, by: u32);
}
/// The Null Object: every call is accepted and ignored. It is a zero-sized type, so the
/// calls cost nothing at all.
struct NoOpMetrics;
impl Metrics for NoOpMetrics {
fn increment(&mut self, _name: &'static str, _by: u32) {}
}
#[derive(Default)]
struct RecordingMetrics {
counts: BTreeMap<&'static str, u32>, // kept sorted by name
}
impl Metrics for RecordingMetrics {
fn increment(&mut self, name: &'static str, by: u32) {
*self.counts.entry(name).or_insert(0) += by;
}
}
struct Checkout<M: Metrics> {
metrics: M, // always some Metrics, so nothing below checks
}
impl Checkout<NoOpMetrics> {
fn new() -> Self {
Checkout {
metrics: NoOpMetrics,
}
}
}
impl<M: Metrics> Checkout<M> {
fn with(metrics: M) -> Self {
Checkout { metrics }
}
fn total(&mut self, prices_paise: &[u32]) -> u32 {
self.metrics
.increment("checkout.items", prices_paise.len() as u32);
self.metrics.increment("checkout.completed", 1);
prices_paise.iter().sum()
}
}
fn main() {
let cart = [49_900, 12_500, 2_000];
println!(
"no metrics configured: {} paise",
Checkout::new().total(&cart)
);
let mut recorded = Checkout::with(RecordingMetrics::default());
println!("metrics recorded: {} paise", recorded.total(&cart));
for (name, count) in &recorded.metrics.counts {
println!(" {name} = {count}");
}
} In Rust: Checkout is generic over its metrics. NoOpMetrics is a zero-sized type, so with it the increment calls compile to nothing; Checkout::new() chooses it, and Checkout::with() takes any other.
Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main
TypeScript 6.0 · main.ts
interface Metrics {
increment(name: string, by: number): void;
}
/** The Null Object: every call is accepted and ignored. */
class NoOpMetrics implements Metrics {
increment(): void {}
}
class RecordingMetrics implements Metrics {
readonly counts = new Map<string, number>();
increment(name: string, by: number): void {
this.counts.set(name, (this.counts.get(name) ?? 0) + by);
}
}
// No state, so one shared instance serves every checkout.
const NO_METRICS: Metrics = new NoOpMetrics();
class Checkout {
readonly #metrics: Metrics;
constructor(metrics: Metrics = NO_METRICS) {
this.#metrics = metrics; // never undefined, so nothing below checks
}
total(pricesPaise: readonly number[]): number {
this.#metrics.increment("checkout.items", pricesPaise.length);
this.#metrics.increment("checkout.completed", 1);
return pricesPaise.reduce((sum, price) => sum + price, 0);
}
}
const cart = [49_900, 12_500, 2_000];
console.log(`no metrics configured: ${new Checkout().total(cart)} paise`);
const recorded = new RecordingMetrics();
console.log(`metrics recorded: ${new Checkout(recorded).total(cart)} paise`);
for (const [name, count] of [...recorded.counts].sort(([a], [b]) => (a < b ? -1 : 1))) {
console.log(` ${name} = ${count}`);
} In TypeScript: The default parameter supplies the Null Object. Interfaces are matched by shape, so NoOpMetrics may declare increment() without the parameters it ignores.
Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts
Go 1.27 · main.go
package main
import (
"fmt"
"slices"
)
// Metrics is what the checkout reports to.
type Metrics interface{ Increment(name string, by int) }
// NoOpMetrics is the Null Object: every call is accepted and ignored.
type NoOpMetrics struct{}
func (NoOpMetrics) Increment(string, int) {}
// RecordingMetrics counts every call, for a test.
type RecordingMetrics struct{ Counts map[string]int }
func (r *RecordingMetrics) Increment(name string, by int) { r.Counts[name] += by }
// Checkout holds a Metrics that is never nil, so its methods do not check.
type Checkout struct{ metrics Metrics }
// NewCheckout puts the Null Object in place when no metrics are configured.
func NewCheckout(m Metrics) *Checkout {
if m == nil {
m = NoOpMetrics{}
}
return &Checkout{metrics: m}
}
func (c *Checkout) Total(pricesPaise []int) int {
c.metrics.Increment("checkout.items", len(pricesPaise))
c.metrics.Increment("checkout.completed", 1)
sum := 0
for _, price := range pricesPaise {
sum += price
}
return sum
}
func main() {
cart := []int{49_900, 12_500, 2_000}
fmt.Printf("no metrics configured: %d paise\n", NewCheckout(nil).Total(cart))
recorded := &RecordingMetrics{Counts: map[string]int{}}
fmt.Printf("metrics recorded: %d paise\n", NewCheckout(recorded).Total(cart))
names := make([]string, 0, len(recorded.Counts))
for name := range recorded.Counts {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
fmt.Printf(" %s = %d\n", name, recorded.Counts[name])
}
} In Go: Calling a method on a nil interface panics, so NewCheckout replaces a nil Metrics with NoOpMetrics{} at the boundary. The names are sorted before printing, because Go randomises the order of a map.
Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go
Swift 6.4 · main.swift
protocol Metrics {
func increment(_ name: String, by: Int)
}
/// The Null Object: every call is accepted and ignored.
struct NoOpMetrics: Metrics {
static let shared = NoOpMetrics() // no state, so one instance serves every checkout
func increment(_ name: String, by: Int) {}
}
final class RecordingMetrics: Metrics {
private(set) var counts: [String: Int] = [:]
func increment(_ name: String, by: Int) {
counts[name, default: 0] += by
}
}
struct Checkout {
var metrics: any Metrics = NoOpMetrics.shared // never nil, so nothing below checks
func total(_ pricesPaise: [Int]) -> Int {
metrics.increment("checkout.items", by: pricesPaise.count)
metrics.increment("checkout.completed", by: 1)
return pricesPaise.reduce(0, +)
}
}
let cart = [49_900, 12_500, 2_000]
print("no metrics configured: \(Checkout().total(cart)) paise")
let recorded = RecordingMetrics()
print("metrics recorded: \(Checkout(metrics: recorded).total(cart)) paise")
for (name, count) in recorded.counts.sorted(by: { $0.key < $1.key }) {
print(" \(name) = \(count)")
} In Swift: The stored property's default value is NoOpMetrics.shared, so a Checkout() made with no arguments gets it. RecordingMetrics is a class, so the increments reach the object the test reads.
Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main
Ruby 4.0 · main.rb
# frozen_string_literal: true
# The Null Object: every call is accepted and ignored.
class NoOpMetrics
def increment(_name, _by); end
end
# Counts every call, for a test.
class RecordingMetrics
attr_reader :counts
def initialize
@counts = Hash.new(0)
end
def increment(name, by)
@counts[name] += by
end
end
NO_METRICS = NoOpMetrics.new.freeze # no state, so one shared instance serves every checkout
# A checkout whose metrics are never nil, so nothing in it checks.
class Checkout
def initialize(metrics = NO_METRICS)
@metrics = metrics
end
def total(prices_paise)
@metrics.increment('checkout.items', prices_paise.size)
@metrics.increment('checkout.completed', 1)
prices_paise.sum
end
end
cart = [49_900, 12_500, 2_000]
puts "no metrics configured: #{Checkout.new.total(cart)} paise"
recorded = RecordingMetrics.new
puts "metrics recorded: #{Checkout.new(recorded).total(cart)} paise"
recorded.counts.sort.each { |name, count| puts " #{name} = #{count}" } In Ruby: NO_METRICS is a frozen constant and the default argument, so every checkout without metrics shares one null object. Hash.new(0) starts every count at zero.
Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb
Kotlin 2.4 · Main.kt
interface Metrics {
fun increment(
name: String,
by: Int,
)
}
/** The Null Object: every call is accepted and ignored; no state, so one object serves all. */
object NoOpMetrics : Metrics {
override fun increment(
name: String,
by: Int,
) {}
}
class RecordingMetrics : Metrics {
val counts = mutableMapOf<String, Int>()
override fun increment(
name: String,
by: Int,
) {
counts[name] = (counts[name] ?: 0) + by
}
}
class Checkout(
private val metrics: Metrics = NoOpMetrics, // never null, so nothing below checks
) {
fun total(pricesPaise: List<Int>): Int {
metrics.increment("checkout.items", pricesPaise.size)
metrics.increment("checkout.completed", 1)
return pricesPaise.sum()
}
}
fun main() {
val cart = listOf(49_900, 12_500, 2_000)
println("no metrics configured: ${Checkout().total(cart)} paise")
val recorded = RecordingMetrics()
println("metrics recorded: ${Checkout(recorded).total(cart)} paise")
for ((name, count) in recorded.counts.entries.sortedBy { it.key }) println(" $name = $count")
} In Kotlin: object NoOpMetrics declares a singleton, the natural home for a Null Object without state, and it is the default value of the constructor parameter.
Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
Dart 3.13 · main.dart
abstract interface class Metrics {
void increment(String name, int by);
}
/// The Null Object: every call is accepted and ignored.
final class NoOpMetrics implements Metrics {
const NoOpMetrics(); // no state, so one constant instance serves every checkout
@override
void increment(String name, int by) {}
}
final class RecordingMetrics implements Metrics {
final counts = <String, int>{};
@override
void increment(String name, int by) =>
counts[name] = (counts[name] ?? 0) + by;
}
final class Checkout {
const Checkout([this._metrics = const NoOpMetrics()]); // never null
final Metrics _metrics;
int total(List<int> pricesPaise) {
_metrics.increment('checkout.items', pricesPaise.length);
_metrics.increment('checkout.completed', 1);
return pricesPaise.fold(0, (sum, price) => sum + price);
}
}
void main() {
const cart = [49900, 12500, 2000];
print('no metrics configured: ${const Checkout().total(cart)} paise');
final recorded = RecordingMetrics();
print('metrics recorded: ${Checkout(recorded).total(cart)} paise');
for (final name in recorded.counts.keys.toList()..sort()) {
print(' $name = ${recorded.counts[name]}');
}
} In Dart: const NoOpMetrics() is a compile-time constant, so every checkout shares one instance, and the optional positional parameter defaults to it.
Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart
What every language printed
no metrics configured: 64400 paise metrics recorded: 64400 paise checkout.completed = 1 checkout.items = 3
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 12 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 152 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 3 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 9 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 21 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 723 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 193 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 18 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
With nothing configured the checkout gets the shared Null Object; the test passes RecordingMetrics and reads its
counts. The total is 64400 paise both ways, and that is what makes this Null Object safe: doing nothing never changes
what the caller gets back. NoOpMetrics holds no state, so one instance can serve every checkout in the program.
Notice the default: the constructor’s default value is the shared Null Object, not None, so there is no moment at
which the checkout’s metrics can be missing. In an application with a
composition root: The one place, usually where the program starts, that creates an application's objects and connects them, choosing for example which metrics client or payment gateway each part receives., the root makes the choice: it passes a real metrics client when
one is configured and the Null Object when none is, and the checkout never learns which it received.
Key idea
A Null Object is safe when doing nothing is a correct outcome: the caller’s result stays the same, so the checks for a missing collaborator can go, and one stateless instance serves everyone.
Checkpoint
Check: A Null Object in code
Special cases in the domain: guests, unknown accounts and checks counted Pro: opens with a Pro, Premium or Ultimate pass
A Special Case carries the rules of one situation instead of silence. This part prices one cart for a registered customer, a guest and an account the directory has never heard of, then counts the checks for a missing customer before and after.
In this part:
- One cart, three customers, and what None got wrong
- Counting the checks: six before, none in the pricing code after
- Null Object or Special Case
2 runnable examples (Python, customers.py, demo.py, pricing.py, pricing_before.py) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Option types or a Null Object: choosing per call site, in 14 languages Pro: opens with a Pro, Premium or Ultimate pass
Option types and Null Objects answer different questions, and most code bases need both. This part shows each language’s way of saying “nothing found”, what happens when a caller ignores it, and how to choose at each call site.
In this part:
- Fourteen languages, one lookup that may find nothing
- Python: None in the type, and a sentinel for “not passed”
- Choosing per call site
2 runnable examples (Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
When a Null Object hides a bug: a silent payment gateway, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A Null Object is safe only when doing nothing is a correct outcome. This part reproduces a shop that marked 3,597 rupees of orders as paid without receiving any of it, then moves the decision to start-up, where a missing setting can still be fixed.
In this part:
- Three orders marked paid, no money received
- Required or optional: decide once, at start-up
- Exercise: take nine checks for None out of a checkout
- Take nine checks for None out of a checkout
- Results of the sample tests
1 diagram · 1 runnable example (Python) · 1 exercise with sample tests · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: the four answers to a missing value
These questions cover the first three sections, why the checks spread, the four answers and the Null Object in code, starting with the idea you have answered least well:
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What is a Null Object, and how is it different from returning None or null?
A Null Object implements the same interface as the real collaborator and does nothing harmful: a NoOpMetrics accepts every count and records none, a NoDiscount takes nothing off. Callers use it like any other object, so they need no checks. Returning None or null instead pushes a decision into every caller, which must remember to check before each call; a forgotten check fails at run time, and the checks that remain crowd out the business rules. A Null Object fits only when doing nothing is a correct outcome, such as optional metrics or auditing. When each caller must decide what absence means, an Option type such as Java's Optional is better, and when absence is a configuration mistake, the program should fail fast instead.
-
Mid to senior
A teammate replaced the null checks around optional collaborators with Null Objects, and wrote a NullPaymentGateway the same way for environments without a payment key. Finance now reports orders marked paid but never charged. Explain the failure, how you would find the affected orders, and the rule you would set.
-
Mid to senior
Design the lookup methods of a customer directory used by checkout, support and marketing. When should a lookup return an Optional (or Option), when a Special Case object such as GuestCustomer, and when should it throw an exception?
-
Senior to staff
NullPointerExceptions are the most common production error in a large Java service. Plan a migration that reduces them without a rewrite. What do you change first, and how do you measure progress?
Key takeaways
- A Null Object implements the interface and does nothing harmful, so callers stop checking for it, and one stateless instance can serve every caller.
- A Special Case carries the rules of one known situation, such as a guest checkout, in one class instead of many callers.
- Option types put absence into the type, so each caller has to decide what nothing found means.
- When an absence is a mistake, fail fast instead of covering it with a do-nothing object.
Terms in this lesson
- composition root
- The one place, usually where the program starts, that creates an application's objects and connects them, choosing for example which metrics client or payment gateway each part receives.
- fail fast
- To raise an error as soon as a mistake is detected, ideally at start-up, instead of carrying on with a missing or invalid value that would do harm later.
- Null Object
- An object that implements the interface its callers expect and does nothing harmful, such as a NoOpMetrics that ignores every count, so callers need not check whether a collaborator is there.
- option type
- A type that holds either a value or nothing, such as Optional<T> in Java or Option<T> in Rust, so that each caller has to decide what an empty result means.
- Special Case
- An object that stands for one known situation, such as a guest checkout, and answers the questions callers ask with the rules of that situation instead of returning nothing.
References
- Null References: The Billion Dollar Mistake (InfoQ)
- Patterns of Enterprise Application Architecture catalog: Special Case (Martin Fowler)
- Java SE 25 API: java.util.Optional (Oracle)
- TSConfig reference: strictNullChecks (Microsoft (TypeScript))
- TypeScript 6.0 release notes (Microsoft (TypeScript))
- Rust standard library: std::option (The Rust Project)
- The Go Programming Language Specification: selectors and index expressions (The Go Authors)
- C++ working draft: optional objects ([optional]) (ISO C++ committee (working draft))
- Nullable reference types (C# language reference) (Microsoft)
- Null safety (Kotlin documentation) (JetBrains)
- typing: Optional and union types (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress