← Null object lesson
Lab

Null object

type TraceLine = { label: string; cents: number };

// A trace accepts explanation lines. It does not promise to keep them.
interface QuoteTrace {
  note(line: TraceLine): void;
}

// Keeps every line, for the "why this price?" view.
class RecordingTrace implements QuoteTrace {
  lines: TraceLine[] = [];
  note(line: TraceLine): void {
    this.lines.push(line);
  }
}

// The null object: a real trace whose note keeps nothing.
const noTrace: QuoteTrace = {
  note(): void {},
};

type Item = { name: string; unitCents: number; quantity: number };
type Quote = {
  subtotal: number;
  discount: number;
  shipping: number;
  tax: number;
  total: number;
};

// Required: a rate for a known region, or an error. Never a made-up zero.
class MemoryRates {
  percents = new Map([['north', 8], ['south', 5]]);
  percentFor(region: string): number {
    const percent = this.percents.get(region);
    if (percent === undefined) throw new Error(`unknown-region-${region}`);
    return percent;
  }
}

// Pricing calls note at every step and never asks which trace it has.
function priceOrder(
  items: Item[], code: string, region: string,
  rates: MemoryRates, trace: QuoteTrace,
): Quote {
  const percent = rates.percentFor(region);
  let subtotal = 0;
  for (const item of items) subtotal += item.unitCents * item.quantity;
  trace.note({ label: 'subtotal', cents: subtotal });
  const discount = code === 'WELCOME10' ? Math.floor(subtotal / 10) : 0;
  trace.note({ label: `discount ${code}`, cents: discount });
  const taxable = subtotal - discount;
  const shipping = taxable >= 5000 ? 0 : 500;
  trace.note({ label: 'shipping', cents: shipping });
  const tax = Math.floor((taxable * percent + 50) / 100);
  trace.note({ label: `tax ${region} ${percent}%`, cents: tax });
  const total = taxable + shipping + tax;
  trace.note({ label: 'total', cents: total });
  return { subtotal, discount, shipping, tax, total };
}

const rates = new MemoryRates();
const items: Item[] = [
  { name: 'Notebook', unitCents: 1200, quantity: 2 },
  { name: 'Pen', unitCents: 300, quantity: 4 },
];

// The "why this price?" view explains; the invoice batch does not.
const recording = new RecordingTrace();
const explained = priceOrder(items, 'WELCOME10', 'north', rates, recording);
const silent = priceOrder(items, 'WELCOME10', 'north', rates, noTrace);
const kept = recording.lines.length;
const sameTotal = explained.total === silent.total;

// A missing rate is a failure in either mode, not a zero.
let missing = '';
try {
  priceOrder(items, 'WELCOME10', 'island', rates, noTrace);
} catch (error) {
  missing = (error as Error).message;
}

console.log(`explained: total ${explained.total}, ${kept} lines kept`);
console.log(`silent: total ${silent.total}, same total: ${sameTotal}`);
console.log(`island: ${missing}`);