Skip to content
aipatterns.dev

Refactoring techniqueSimplifying Conditional Expressions

Introduce Null Object

Replace repeated null checks for a missing collaborator with an object that implements the same interface and does the safe default.

Also known as
Null Object

Problem

Many callers check whether an optional collaborator exists before using it, and each supplies the same fallback: skip the call, use zero, use "Guest". The checks are copied across the codebase, and one missing check is a crash.

Solution

Create a class that implements the collaborator's interface with neutral behavior, and return or inject it wherever null was used. Callers then call methods without checking.

Before

TypeScript
interface Analytics {
  track(event: string, props: Record<string, unknown>): void;
  flush(): Promise<void>;
}

class CheckoutService {
  constructor(private analytics: Analytics | null) {}

  async complete(order: Order): Promise<void> {
    await this.payments.capture(order);
    if (this.analytics) {
      this.analytics.track("checkout_completed", { total: order.total });
    }
    await this.emails.sendReceipt(order);
    if (this.analytics) {
      await this.analytics.flush();
    }
  }
}

After

TypeScript
class NoopAnalytics implements Analytics {
  track(): void {}
  async flush(): Promise<void> {}
}

class CheckoutService {
  constructor(private analytics: Analytics = new NoopAnalytics()) {}

  async complete(order: Order): Promise<void> {
    await this.payments.capture(order);
    this.analytics.track("checkout_completed", { total: order.total });
    await this.emails.sendReceipt(order);
    await this.analytics.flush();
  }
}

Why

  • The default behavior is defined once, inside the null object, instead of at every call site.
  • Callers become straight-line code.
  • Tests can pass the null object explicitly to switch off a dependency.

Steps

  1. Confirm that callers handle the missing case the same way everywhere. Note the fallback value or action.
  2. Create the null class implementing the same interface, with methods that return the fallback. Run the tests.
  3. Change the source of the value (constructor default, factory, repository lookup) to return the null object instead of null. Keep the old checks in place; they now see a non-null object and still work. Run the tests.
  4. Remove the checks one call site at a time. Run the tests after each.
  5. If some caller needs to know the object is "missing", add an isNull() method or keep that caller's check against the type, but try to avoid it.

Watch out

  • Do not use a null object when absence is an error. A missing payment gateway should fail loudly; a silent no-op hides misconfiguration.
  • Do not use it when callers react differently to absence. Then the null object cannot pick one right default, and an Option/undefined with explicit handling is clearer.
  • In TypeScript, Kotlin, Swift or C#, optional chaining (analytics?.track(...)) removes most of the noise for a few call sites. Use a null object when the fallback is more than "do nothing" or appears in many places.
  • Make null objects stateless and shareable, or each instance quietly accumulates data.
  • No inverse technique exists; to undo, reinstate null and the checks.

Agent checklist

  • Every replaced null check used the same fallback.
  • Absence is a legitimate state, not a configuration error.
  • The null object implements the full interface with neutral return values.
  • Remaining null returns from the source are gone, and tests pass.

Treats these smells

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.