Skip to content
academia.sh

Lesson 07 / 16

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.

Contents

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.

mkdir -p absent contract
// 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.

// 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.

// 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”.

// 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.

// 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 }])],
];
// 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
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close