---
title: 'Do Not Pass Null'
source: 'https://academia.sh/en/courses/clean-code/dont-pass-null'
course: 'Clean Code'
language: en
updated: '2026-08-23T07:01:11+00:00'
license: 'CC BY-SA 4.0'
---

# Do Not Pass Null

Measuring how visible a null value is in a contract: how many calls after a null-returning chain an error surfaces, how that same distance drops to zero in a version that shows the missing case in the result type, and replacing signatures that pass null as an argument with an empty collection.

The decisions so far concerned the code's internal structure: names, levels of
abstraction, format, branching, and the shape of options. What a function tells the
outside world has not been addressed yet. The `tariff` function in the previous lesson
threw an error for an unknown type; the common solution to the same problem is to return
a null value.

A signature that returns a null value tells the caller nothing. Someone looking at the
`findCarrier(zoneCode)` signature assumes the result is always a carrier; they learn it
can come back null only by reading the implementation. When the assumption turns out to
be wrong, the error surfaces not where the null value was produced but where it was used.
That distance can be measured.

## The Null Value's Journey

The routing module selects a carrier for a shipment, determines the transfer count, and
calculates the delivery date. Each function writes its name to a trace when called; that
trace will be used for measurement.

```sh
mkdir -p absent contract
```

```js
// absent/route.mjs — a null value is returned when no carrier is found
export const TRACE = [];
const trace = (name) => TRACE.push(name);

const CARRIERS = { 1: { name: "local", deliveryDays: 1 }, 2: { name: "zonal", deliveryDays: 3 },
  3: { name: "remote", deliveryDays: 5 } };

export function findCarrier(zoneCode) {
  trace("findCarrier");
  return CARRIERS[zoneCode] ?? null;
}

export function selectRoute(shipment) {
  trace("selectRoute");
  return { carrier: findCarrier(shipment.zoneCode), transferCount: shipment.zoneCode > 2 ? 1 : 0 };
}

export function transferDelay(route) {
  trace("transferDelay");
  return route.transferCount * 2;
}

export function carrierDelay(route) {
  trace("carrierDelay");
  return route.carrier.deliveryDays;
}

export function deliveryDate(route, today) {
  trace("deliveryDate");
  return today + transferDelay(route) + carrierDelay(route);
}

export function deliveryPlan(shipment, today) {
  trace("deliveryPlan");
  const route = selectRoute(shipment);
  return { route, date: deliveryDate(route, today) };
}

export function processOrder(shipment, today) {
  trace("processOrder");
  return `${shipment.code}: day ${deliveryPlan(shipment, today).date}`;
}
```

When an undefined zone code arrives, `findCarrier` returns null, `selectRoute` packages
this null value as a field and returns it, `deliveryPlan` takes the route, `deliveryDate`
calls the two delay functions, and the error surfaces in the second of them. The null
value travels by leaving the place it was produced and settling inside a data structure.

## The Null Value Visible in the Contract

In the second version, exactly one thing changes: the **result type** of the function that
looks up the carrier. It now returns either a carrier or not-found information, and the
two are distinguishable. The caller must check which one it got before using the result.

```js
// contract/route.mjs — the null value appears in the result type
export const TRACE = [];
const trace = (name) => TRACE.push(name);

const CARRIERS = { 1: { name: "local", deliveryDays: 1 }, 2: { name: "zonal", deliveryDays: 3 },
  3: { name: "remote", deliveryDays: 5 } };

// The result takes one of two shapes: { found: true, carrier } or { found: false, zoneCode }.
export function selectCarrier(zoneCode) {
  trace("selectCarrier");
  const carrier = CARRIERS[zoneCode];
  return carrier === undefined ? { found: false, zoneCode } : { found: true, carrier };
}

export function selectRoute(shipment) {
  trace("selectRoute");
  const result = selectCarrier(shipment.zoneCode);
  if (result.found === false) {
    throw new RangeError(`no carrier defined for zone ${result.zoneCode}`);
  }
  return { carrier: result.carrier, transferCount: shipment.zoneCode > 2 ? 1 : 0 };
}

export function transferDelay(route) {
  trace("transferDelay");
  return route.transferCount * 2;
}

export function carrierDelay(route) {
  trace("carrierDelay");
  return route.carrier.deliveryDays;
}

export function deliveryDate(route, today) {
  trace("deliveryDate");
  return today + transferDelay(route) + carrierDelay(route);
}

export function deliveryPlan(shipment, today) {
  trace("deliveryPlan");
  const route = selectRoute(shipment);
  return { route, date: deliveryDate(route, today) };
}

export function processOrder(shipment, today) {
  trace("processOrder");
  return `${shipment.code}: day ${deliveryPlan(shipment, today).date}`;
}
```

The downstream flow — the delay calculations, the delivery date, the plan, and the
summary — is identical in both versions. The only thing that changes is where the null
value is handled.

## Measurement

The measurer feeds the same invalid shipment to both versions and records three things:
the call trace, the distance between the call that produces the null value and the call
where the error appears, and which of the module's functions appear in the stack trace at
the moment of the error.

```js
// measure.mjs — distance between the call that produces the null value and the call where the error appears
import { TRACE as absentTrace, processOrder as absentProcess } from "./absent/route.mjs";
import { TRACE as contractTrace, processOrder as contractProcess } from "./contract/route.mjs";

const shipment = { code: "GN-4172", zoneCode: 9 };

function measure(title, process, trace, producer) {
  console.log(title);
  try {
    console.log(`  result: ${process(shipment, 12)}`);
  } catch (error) {
    const frames = error.stack.split("\n").slice(1)
      .map((s) => s.match(/at (\w+)/)?.[1]).filter((a) => trace.includes(a));
    console.log(`  call trace: ${trace.join(" -> ")}`);
    console.log(`  call that produces the null value #${trace.indexOf(producer)}, call where the error appears #${trace.length - 1},` +
      ` calls in between = ${trace.length - 1 - trace.indexOf(producer)}`);
    console.log(`  module frames in the stack trace: ${frames.join(", ")}`);
    console.log(`  error: ${error.constructor.name}: ${error.message}`);
  }
}

measure("version that returns a null value", absentProcess, absentTrace, "findCarrier");
measure("version with the null value in the contract", contractProcess, contractTrace, "selectCarrier");
```

```
version that returns a null value
  call trace: processOrder -> deliveryPlan -> selectRoute -> findCarrier -> deliveryDate -> transferDelay -> carrierDelay
  call that produces the null value #3, call where the error appears #6, calls in between = 3
  module frames in the stack trace: carrierDelay, deliveryDate, deliveryPlan, processOrder
  error: TypeError: Cannot read properties of null (reading 'deliveryDays')
version with the null value in the contract
  call trace: processOrder -> deliveryPlan -> selectRoute -> selectCarrier
  call that produces the null value #3, call where the error appears #3, calls in between = 0
  module frames in the stack trace: selectRoute, deliveryPlan, processOrder
  error: RangeError: no carrier defined for zone 9
```

The distance dropped from three to zero. The number represents the path a debugger must
walk: in the null-returning version, the error appears inside `carrierDelay`, and nothing
is wrong there. The stack trace does not help either, because the `findCarrier` function
that produced the null value has already returned; its name is absent from the trace. The
debugger must find a cause that is absent from all four listed frames.

The error messages are also part of the measurement. The first is a type error and says
nothing about the domain; the second names the zone code and the missing thing by name.

## Passing a Null Value

The reverse direction of the same problem is passing a null value **as an argument**. The
discount calculation is an example: passing null when there is no discount produces two
meanings in the signature — "a discount object" and "no discount".

```js
// absent/discount.mjs — a missing discount or campaign is represented as an absent value
export function calculateFee(baseFee, discount, campaign) {
  const rate = discount === null ? 0 : discount.rate;
  const extra = campaign === null ? 0 : campaign.extraDiscount;
  return baseFee * (1 - rate - extra);
}

export const EXAMPLES = [
  ["no discount", calculateFee(100, null, null)],
  ["contracted", calculateFee(100, { rate: 0.15 }, null)],
  ["two discounts", calculateFee(100, { rate: 0.15 }, { extraDiscount: 0.05 })],
];
```

The natural counterpart of the null value is an **empty collection**. If the number of
discounts can be zero, one, or more, the parameter is a list rather than a single object;
the absence of discounts is an empty list and supports the same operations.

```js
// contract/discount.mjs — an empty array carries the absent case, the signature accepts no such value
export function calculateFee(baseFee, discounts) {
  const total = discounts.reduce((t, d) => t + d.rate, 0);
  return baseFee * (1 - total);
}

export const EXAMPLES = [
  ["no discount", calculateFee(100, [])],
  ["contracted", calculateFee(100, [{ rate: 0.15 }])],
  ["two discounts", calculateFee(100, [{ rate: 0.15 }, { rate: 0.05 }])],
];
```

```js
// discount-measure.mjs — the two signatures produce identical results, and the null value count
import { EXAMPLES as absentExamples } from "./absent/discount.mjs";
import { EXAMPLES as contractExamples } from "./contract/discount.mjs";

for (const [i, [label, value]] of absentExamples.entries()) {
  const other = contractExamples[i][1];
  console.log(`${label.padEnd(14)} null=${value.toFixed(2)}  contract=${other.toFixed(2)}` +
    `  ${value.toFixed(2) === other.toFixed(2) ? "same" : "DIFFERENT"}`);
}
```

```
no discount    null=100.00  contract=100.00  same
contracted     null=85.00  contract=85.00  same
two discounts  null=80.00  contract=80.00  same
```

```sh
for d in absent contract; do echo "$d/discount.mjs: $(grep -o null $d/discount.mjs | wc -l | tr -d ' ') null value(s)"; done
```

```
absent/discount.mjs: 5 null value(s)
contract/discount.mjs: 0 null value(s)
```

The same three results are produced with zero null values instead of five. The gain is not
only in the count: the empty-collection signature stays unchanged when a new kind of
discount is added, while the null-based signature demands one more parameter and one more
check for every new discount.

## Ways to Make the Null Value Visible

The measurement does not say which technique to choose; all three options bring the
distance to zero, and their differences lie elsewhere.

**Fail fast.** If the null value is a symptom of a programming error, it is thrown where
it is produced. `selectRoute` does this: an undefined zone code is a data defect, and
continuing the calculation makes no sense. This option leaves no decision to the caller.

**Empty collection.** If the null value means "none at all" and the operation is still
meaningful with zero elements, an empty list, an empty map, or zero is used. The discount
example is this case; the caller writes no check, because the absence is already a
supported case.

**Explicit result type.** If the null value is an expected outcome and the caller should
decide what to do, the result takes one of two shapes, and which one it is can be read.
`selectCarrier` does this: one caller can choose to throw, another can choose to fall back
to a default carrier.

What the three share is that the null value becomes **part of the signature**. Returning a
null value, by contrast, hides it from the signature; no one who skips the implementation
knows it exists.

## Summary

- A signature that returns a null value hides it; the caller cannot verify their
  assumption without reading the implementation.
- In the measurement, the error appeared three calls after the null value was produced;
  the error surfaced inside `carrierDelay`, and the function that produced the null value
  did not appear in the stack trace.
- In the version that shows the null value in the result type, the distance dropped to
  zero, and the error message turned from a type error into a domain error that names the
  zone code.
- When a null value is passed as an argument, every parameter demands a check; the
  signature with the empty collection produced the same three results with zero null
  values instead of five.
- The three ways to make the null value visible are fail fast, empty collection, and
  explicit result type; all three make it part of the signature.

## Next Step

The `calculateFee` signature is now free of null values, but a similar problem returns in
a different shape. The fee library also calculates return shipments, and the two
calculations are largely the same; adding a boolean value to the signature to carry the
difference seems reasonable. Such a parameter says not a single word at the call site and
opens a branch inside the function. The next lesson measures its cost through testing: how
many cases are needed to test the single function with a flag against the two separate
functions.
