Beginner10 min readDesign Patternslive prototype

Null Object

Instead of returning `null` when something is missing, return a real object of the same interface that safely does nothing. A walk-in with no loyalty card becomes a `GuestCustomer` holding a `NoDiscountPlan` (0% off, no perks) — so `customer.getPlan().getDiscount()` works for *everyone*, and nobody ever writes `if (customer != null)` again.

The idea

What it is

Null Object answers one question: what should a method return when there's nothing to return? Picture a coffee-shop loyalty system. shop.lookupCustomer(cardId) finds the customer for a swiped card, and checkout applies their plan's discount with one tidy chain: customer.getPlan().getDiscount(). Then a walk-in arrives with no card. The naive answer — return null — leaves the caller holding a live grenade: forget one check and .getPlan() on null crashes the register.

So teams defend themselves the only way null allows: if (customer != null && customer.getPlan() != null) { … } — copied into every method that touches a customer. The Null Object pattern deletes that entire tax. When there's no real customer, lookupCustomer returns a GuestCustomer: a genuine object with the same interface, whose plan is a NoDiscountPlan — discount 0%, perks empty, side effects none. The chain runs end to end for everyone, and the 'nobody' case is handled once, inside the null object, instead of everywhere it's used.

The one sentence to remember

Replace null with a real object of the same interface that safely does nothing — neutral values, no-op methods — so callers never have to check for absence.

The billion-dollar mistake

Tony Hoare, who introduced the null reference in 1965, later called it his billion-dollar mistake for all the crashes and defensive code it has caused since — Null Object is one of the classic ways to design that mistake out of your APIs.

Mechanics

How it works

The three participants

The pattern is tiny — an interface and two kinds of implementation of it:

  • The abstract type — the interface callers program against: Customer (with getPlan()) and Plan (with getDiscount(), perks()). Callers only ever see this type, never a concrete class.
  • Real implementations — the objects with actual behaviour: a MemberCustomer whose GoldPlan returns 20% off and real perks.
  • The Null implementationGuestCustomer and NoDiscountPlan: same interface, neutral behaviour. Numbers return 0, collections return empty (never null), actions are no-ops. It does nothing — safely.

Because a null object holds no state and always behaves the same, you usually create one shared instance and hand it out everywhere — a natural little [[singleton]]. There's no point allocating a fresh NoDiscountPlan per walk-in when every one of them is identical.

typescript
interface Plan {
  getDiscount(): number;                 // percent off
}

class GoldPlan implements Plan {
  getDiscount() { return 20; }           // real behaviour
}

class NoDiscountPlan implements Plan {   // the Null Object
  static readonly INSTANCE = new NoDiscountPlan();  // stateless → share one
  getDiscount() { return 0; }            // neutral: safely does nothing
}

// lookupCustomer() NEVER returns null — walk-ins get a GuestCustomer
// whose getPlan() is NoDiscountPlan.INSTANCE. So checkout is one line:
const price = 10 * (1 - shop.lookupCustomer(card).getPlan().getDiscount() / 100);

Where it shines

  • Chained callsa().b().c() is only safe if no link can be null. Null objects keep the whole chain alive with neutral values, exactly like the checkout line above.
  • Collections mixing real and 'missing' entries — loop over customers and call getPlan().getDiscount() on every element without filtering; guests simply contribute 0.
  • Default collaborators — give a class a NullLogger by default so its code calls logger.log(...) unconditionally; wire in a real logger only when you want output.

The honest caveat: it can hide real errors

A null object swallows absence. That's the feature — and the danger. If 'missing' is a genuine error the caller must know about (an unknown payment account, a required config key), a silent do-nothing object turns a loud crash into a quiet wrong answer that surfaces days later. Reserve the pattern for cases where doing nothing is correct, not merely convenient.

The modern spectrum for 'maybe absent'

Null Object is one point on a spectrum. Java's Optional<Customer> and TypeScript's Customer | undefined with optional chaining (customer?.getPlan()) make absence visible in the type and force callers to decide; a null object makes absence invisible and decides for them. Use the type-level tools when callers should care, and a null object when they genuinely shouldn't.

You've already met this pattern in the wild: a NullLogger (log calls go nowhere), the guest user/session every web framework hands to signed-out visitors, a no-op cache used when caching is disabled, and the empty iterator — the reason for (x of emptyList) just runs zero times instead of crashing.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A coffee-shop checkout lane. The CODE card shows the one chained line every checkout runs: lookupCustomer(card).getPlan().getDiscount(). Flip between two worlds and swipe. In WITHOUT Null Object, 💳 Member card works fine ($10.00 → $8.00 ✓), but 🪪 No card makes the lookup return a red null — the chain reaches .getPlan() and 💥 explodes with a NullPointerException. Press 🩹 Add null checks and the tidy one-liner turns into a red 5-line if-pyramid (checks: 2) that survives the swipe — that's the tax null makes you pay in every method. Flip to WITH Null Object: 🪪 No card now returns a calm grey GuestCustomer with a NoDiscountPlan, the same one-line chain runs end-to-end green ($10.00 → $10.00 ✓, checks: 0), and no crash is possible. Same code line, no ifs — that's the whole pattern.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Crash the WITHOUT world

With the toggle on ✕ WITHOUT Null Object, press 💳 Member card — the lookup returns a gold Customer, the chain lights up left to right, and the price drops $10.00 → $8.00 ✓. Now press 🪪 No card: the lookup returns a red null token, the pulse reaches .getPlan() and 💥 explodes — NullPointerException, price ☠. Same line of code, one missing card, dead register.

try 02

Pay the null-check tax

Press 🩹 Add null checks. The tidy one-liner on the CODE card turns into a red 5-line if-null pyramid and the counter jumps to checks: 2. Swipe 🪪 No card again — it survives now, but look at what checkout code became. Multiply that pyramid by every method that touches a customer: that's the tax null makes the whole codebase pay.

try 03

Flip to WITH and compare

Switch the toggle to ✓ WITH Null Object — the code is the original one-liner again and the counter reads checks: 0. Press 🪪 No card: the lookup returns a calm grey GuestCustomer with a NoDiscountPlan, the same chain runs end-to-end green, and the price stays $10.00 ✓. Press 💳 Member card to confirm members still get $8.00, then ↺ Reset and replay both worlds — identical code line, zero ifs, no crash possible.

In practice

When to use it — and what trips people up

Use it when doing nothing is the correct answer

  • A sane do-nothing default exists — 0% discount, empty perks, a log call that goes nowhere. If the neutral behaviour is genuinely right for the missing case, a null object encodes it once.
  • Callers shouldn't care about absence — checkout should not branch on 'is this a member?'; it should just ask the plan for a discount and get 0 for guests.
  • Chained calls and collection loops — anywhere null would force a guard before every dot or a filter before every loop.
  • Optional collaborators — loggers, metrics, caches, notifiers that a class should be able to call unconditionally whether or not one is configured.

Skip it when absence is an error

  • The caller must know something is missing — an unknown payment account, a required config key, a failed lookup that should stop the flow. A silent no-op here converts a loud, debuggable crash into quiet wrong behaviour.
  • Different callers need different reactions to absence — one wants a retry, one wants an error page. A null object bakes in a single reaction (nothing); prefer Optional/Maybe types or explicit | undefined handling so each caller decides.
  • You'd need the null object to lie — if GuestCustomer had to fake a name, an ID, and an address to satisfy the interface, the 'neutral' behaviour isn't neutral anymore. That's a sign the design wants explicit absence.

What it gives you

  • Deletes repetitive null checks — the 'nobody' case is implemented once, inside the null object, instead of guarded before every call site.
  • Makes chained calls and loops safe by construction — every link in a().b().c() is a real object, so the happy path is the only path.
  • Callers get simpler and polymorphic — checkout treats members and guests identically through one interface, with no if (guest) branching.
  • Cheap to share — null objects are stateless, so one instance (a mini [[singleton]]) serves the whole program with zero allocation cost.

Common mistakes

  • Can hide genuine errors — a silent no-op where absence mattered turns crashes into quiet wrong results that are much harder to trace.
  • One extra class per interface — for a type with many methods, writing a neutral version of every method is real boilerplate.
  • One-size-fits-all reaction — every caller gets 'do nothing'; if different callers need different absence handling, the pattern is the wrong tool.
  • Can mask design smells — sprinkling null objects everywhere to keep long a().b().c() chains alive papers over coupling that violates the Law of Demeter.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

interface Plan {
  getDiscount(): number;                     // percent off, e.g. 20
  perks(): string[];
}

class GoldPlan implements Plan {
  getDiscount() { return 20; }
  perks() { return ["free refill", "birthday drink"]; }
}

// The Null Object: same interface, neutral behaviour.
class NoDiscountPlan implements Plan {
  static readonly INSTANCE = new NoDiscountPlan(); // stateless → share one
  getDiscount() { return 0; }                      // neutral number
  perks(): string[] { return []; }                 // empty, never null
}

interface Customer {
  getName(): string;
  getPlan(): Plan;
}

class MemberCustomer implements Customer {
  constructor(private name: string) {}
  getName() { return this.name; }
  getPlan(): Plan { return new GoldPlan(); }
}

class GuestCustomer implements Customer {          // Null Object for Customer
  getName() { return "Guest"; }
  getPlan(): Plan { return NoDiscountPlan.INSTANCE; }
}

class Shop {
  private members = new Map<string, Customer>([["gold-7", new MemberCustomer("Mia")]]);
  lookupCustomer(cardId?: string): Customer {      // NEVER returns null
    return (cardId && this.members.get(cardId)) || new GuestCustomer();
  }
}

// Checkout: one line, zero null checks, works for members AND walk-ins.
const shop = new Shop();
const price = 10 * (1 - shop.lookupCustomer("gold-7").getPlan().getDiscount() / 100);
console.log(price);                                 // 8 — guest would print 10

References & further reading

5 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 05

What is the core idea of the Null Object pattern?

question 02 / 05

Why does shop.lookupCustomer(card).getPlan().getDiscount() never crash once GuestCustomer and NoDiscountPlan exist?

question 03 / 05

NoDiscountPlan holds no state and always behaves the same. What does this suggest about how to create it?

question 04 / 05

What is the main danger of using a Null Object, shown by the honest caveat in this lesson?

question 05 / 05

When should you prefer Java's Optional or TypeScript's | undefined with optional chaining over a Null Object?

0/5 answered