Skip to content
academia.sh

Lesson 06 / 16

Choosing the Right Structure

Writing three versions of the same behavior with a conditional chain, a lookup table, and polymorphism, and tying the choice to a measure: the number of files and lines touched when a new tariff type is added, how many places a type name appears, and the cost of a type carrying its own state.

Contents

The previous lesson moved zone coefficients from a conditional chain into a table, and complexity dropped. This transformation is not always correct. A table returns a value for a key; when the counterpart is not a value but a behavior, or when every option carries its own data, a third option appears: polymorphism.

Choosing among the three looks like a matter of style. It has a measurable side: a design’s cost shows up in the change made to it. This lesson writes the same behavior in three structures, then applies the same change to all three and compares the number of files and lines touched.

The Shared Calculation

All three designs start from the same base fee: tier rate times zone coefficient. Where they diverge is where the tariff type gets selected. There are three types (standard, express, economy), and each type appears in three operations at once: fee, delivery day, label.

mkdir -p conditional table polymorphic
// common.mjs — base fee calculation shared by the three designs
const ZONE_COEFFICIENTS = { 1: 1, 2: 1.35, 3: 1.8 };
const WEIGHT_TIERS = [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 },
  { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }];

export function baseFee(shipment) {
  const tier = WEIGHT_TIERS.find((t) => shipment.weightKg <= t.upperLimitKg);
  return tier.ratePerKg * shipment.weightKg * ZONE_COEFFICIENTS[shipment.zoneCode];
}

Three Designs

Conditional chain. The type decision gets made again in every operation.

// conditional/fee.mjs — tariff type selected by a conditional chain in every operation
import { baseFee } from "../common.mjs";

export function fee(shipment, type) {
  const base = baseFee(shipment);
  if (type === "standard") return base;
  if (type === "express") return base * 1.6 + 25;
  if (type === "economy") return Math.max(base * 0.8, 65);
  throw new RangeError(`unknown tariff: ${type}`);
}

export function deliveryDays(type) {
  if (type === "standard") return 3;
  if (type === "express") return 1;
  if (type === "economy") return 6;
  throw new RangeError(`unknown tariff: ${type}`);
}

export function label(type) {
  if (type === "standard") return "Standard";
  if (type === "express") return "Express";
  if (type === "economy") return "Economy";
  throw new RangeError(`unknown tariff: ${type}`);
}

Lookup table. The type decision gets made once; a type is one data row.

// table/fee.mjs — tariff types in a single table, one type per row
import { baseFee } from "../common.mjs";

const TARIFFS = {
  standard: { label: "Standard", days: 3, calculate: (base) => base },
  express: { label: "Express", days: 1, calculate: (base) => base * 1.6 + 25 },
  economy: { label: "Economy", days: 6, calculate: (base) => Math.max(base * 0.8, 65) },
};

function tariff(type) {
  const selected = TARIFFS[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;

Polymorphism. A type is a class; the classes implement the same members and are recorded in a registry.

// polymorphic/types.mjs — every type implements the same three members
export class Standard {
  label = "Standard";
  days = 3;
  calculate(base) { return base; }
}

export class Express {
  label = "Express";
  days = 1;
  calculate(base) { return base * 1.6 + 25; }
}

export class Economy {
  label = "Economy";
  days = 6;
  calculate(base) { return Math.max(base * 0.8, 65); }
}
// polymorphic/fee.mjs — types are recorded in a registry, selection is made from it
import { baseFee } from "../common.mjs";
import { Standard, Express, Economy } from "./types.mjs";

const REGISTRY = { standard: new Standard(), express: new Express(), economy: new Economy() };

function tariff(type) {
  const selected = REGISTRY[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;

The three designs are confirmed to produce the same results.

// compare.mjs — verifies the three designs give the same results
import * as conditional from "./conditional/fee.mjs";
import * as table from "./table/fee.mjs";
import * as polymorphic from "./polymorphic/fee.mjs";

const shipment = { weightKg: 4.2, zoneCode: 2 };
for (const type of ["standard", "express", "economy"]) {
  const row = [conditional, table, polymorphic].map((m) =>
    `${m.label(type)}/${m.deliveryDays(type)}/${m.fee(shipment, type).toFixed(2)}`);
  const same = row.every((s) => s === row[0]);
  console.log(`${type.padEnd(10)} ${same ? "same  " : "DIFFER"} ${row[0]}`);
}
standard   same   Standard/3/147.42
express    same   Express/1/260.87
economy    same   Economy/6/117.94

Change: a Fourth Type

A cold chain tariff opens: the fee is 2.2 times the base fee plus a forty-unit refrigeration charge, delivery time two days. The change is applied to a copy of all three designs.

cp -r conditional conditional-new && cp -r table table-new && cp -r polymorphic polymorphic-new
// conditional-new/fee.mjs — one more branch in every chain
import { baseFee } from "../common.mjs";

export function fee(shipment, type) {
  const base = baseFee(shipment);
  if (type === "standard") return base;
  if (type === "express") return base * 1.6 + 25;
  if (type === "economy") return Math.max(base * 0.8, 65);
  if (type === "cold-chain") return base * 2.2 + 40;
  throw new RangeError(`unknown tariff: ${type}`);
}

export function deliveryDays(type) {
  if (type === "standard") return 3;
  if (type === "express") return 1;
  if (type === "economy") return 6;
  if (type === "cold-chain") return 2;
  throw new RangeError(`unknown tariff: ${type}`);
}

export function label(type) {
  if (type === "standard") return "Standard";
  if (type === "express") return "Express";
  if (type === "economy") return "Economy";
  if (type === "cold-chain") return "Cold Chain";
  throw new RangeError(`unknown tariff: ${type}`);
}
// table-new/fee.mjs — one more row in the table
import { baseFee } from "../common.mjs";

const TARIFFS = {
  standard: { label: "Standard", days: 3, calculate: (base) => base },
  express: { label: "Express", days: 1, calculate: (base) => base * 1.6 + 25 },
  economy: { label: "Economy", days: 6, calculate: (base) => Math.max(base * 0.8, 65) },
  "cold-chain": { label: "Cold Chain", days: 2, calculate: (base) => base * 2.2 + 40 },
};

function tariff(type) {
  const selected = TARIFFS[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;
// polymorphic-new/cold-chain.mjs — the new type, in its own file
export class ColdChain {
  label = "Cold Chain";
  days = 2;
  calculate(base) { return base * 2.2 + 40; }
}
// polymorphic-new/fee.mjs — one more entry in the registry
import { baseFee } from "../common.mjs";
import { Standard, Express, Economy } from "./types.mjs";
import { ColdChain } from "./cold-chain.mjs";

const REGISTRY = { standard: new Standard(), express: new Express(), economy: new Economy(),
  "cold-chain": new ColdChain() };

function tariff(type) {
  const selected = REGISTRY[type];
  if (selected === undefined) throw new RangeError(`unknown tariff: ${type}`);
  return selected;
}

export const fee = (shipment, type) => tariff(type).calculate(baseFee(shipment));
export const deliveryDays = (type) => tariff(type).days;
export const label = (type) => tariff(type).label;

Measurement

The measurer compares each design’s old and new directories file by file; a removed line is one that changed or was deleted, an added line is one that was inserted.

// change-measure.mjs — how many files and lines the same type addition touches in three designs
import { execSync } from "node:child_process";
import { readdirSync } from "node:fs";

// The first line is the filename marker; it does not enter the comparison.
const diff = (before, after) =>
  execSync(`diff <(tail -n +2 ${before} 2>/dev/null) <(tail -n +2 ${after} 2>/dev/null) || true`,
    { shell: "/bin/bash" }).toString().split("\n");

for (const design of ["conditional", "table", "polymorphic"]) {
  const names = [...new Set([...readdirSync(design), ...readdirSync(`${design}-new`)])].sort();
  let touched = 0, removed = 0, added = 0;
  console.log(design);
  for (const name of names) {
    const lines = diff(`${design}/${name}`, `${design}-new/${name}`);
    const c = lines.filter((s) => s.startsWith("< ")).length;
    const g = lines.filter((s) => s.startsWith("> ")).length;
    if (c + g > 0) touched += 1;
    [removed, added] = [removed + c, added + g];
    console.log(`  ${name.padEnd(18)} removed=${c}  added=${g}`);
  }
  console.log(`  ${"= total".padEnd(18)} files=${touched}  removed=${removed}  added=${added}`);
}
conditional
  fee.mjs            removed=0  added=3
  = total            files=1  removed=0  added=3
table
  fee.mjs            removed=0  added=1
  = total            files=1  removed=0  added=1
polymorphic
  cold-chain.mjs     removed=0  added=5
  fee.mjs            removed=1  added=3
  types.mjs          removed=0  added=0
  = total            files=2  removed=1  added=8

The second measurement counts how many places the type name appears. This number is how many places need to be found when a type changes.

grep -c economy conditional/fee.mjs table/fee.mjs polymorphic/fee.mjs
conditional/fee.mjs:3
table/fee.mjs:1
polymorphic/fee.mjs:1

What the Numbers Say

For this change, the cheapest design is the table: one file, one line. The conditional chain added three lines, because the type decision gets made in three separate places. The source of these three lines is in the second measurement: the name economy appears in three places in the conditional chain, one place in the other two.

The gap grows with the number of operations. When a fourth operation is added for tariff types (an insurance rate, say), the conditional chain gets a fourth function and that function re-counts every type; the table gets one field added to every row. With operation count N and type count T, the conditional chain carries N × T decisions, the table carries T rows.

Polymorphism is the most expensive of the three for this change: two files, eight added lines. Five of the eight lines are the new type’s own file; three are the addition to the registry. In contrast, types.mjs was never opened: the definitions of the existing types went untouched. In the table, too, the existing rows did not change, but the new row went inside the existing table object.

The real difference is not in the line count, it is in what a type can carry. A table row is a data record; every row has to fill the same fields in the same way. A type can carry its own state.

// contract-tariff.mjs — a tariff type that carries its own state: one behavior definition, three instances
import { baseFee } from "./common.mjs";

class ContractTariff {
  constructor(label, days, multiplier, flatFee) {
    Object.assign(this, { label, days, multiplier, flatFee });
  }
  calculate(base) { return base * this.multiplier + this.flatFee; }
}

const CONTRACTS = [
  new ContractTariff("Customer A", 2, 1.1, 0),
  new ContractTariff("Customer B", 3, 0.9, 15),
  new ContractTariff("Customer C", 1, 1.4, 30),
];

const shipment = { weightKg: 4.2, zoneCode: 2 };
for (const c of CONTRACTS) {
  console.log(`${c.label.padEnd(10)} ${c.days} days  ${c.calculate(baseFee(shipment)).toFixed(2)}`);
}
console.log(`tariff count=${CONTRACTS.length}  behavior definitions=1`);
Customer A 2 days  162.16
Customer B 3 days  147.68
Customer C 1 days  236.39
tariff count=3  behavior definitions=1

Three contract tariffs were produced from a single behavior definition. If the contract count were three hundred instead of three, the line count would not change; the only thing that would grow is data. The conditional chain would add three branches per contract, the table one row per contract.

The Selection Criterion

The measurements support three rules.

A conditional chain fits when both the number of types and the number of operations are small and fixed; keeping the decision in one place, in plain view, is a gain. The moment the same distinction is repeated in a second place, its cost starts multiplying.

A lookup table is cheapest when the options share the same shape as data records and selection is nothing more than looking up a key. Its limit is that every row has to fill the same fields; the moment one option needs one extra piece of information, every row’s shape changes.

Polymorphism pays off when each option carries its own state, performs more than one operation together, or the number of options is set by data. It carries a fixed cost — two files and eight lines for this change — and that cost is wasted when the options are plain data.

Summary

  • The same behavior was written in three structures, and all three produced the same results; the comparison was made with behavior held constant.
  • Adding a fourth tariff type required one line in one file for the table, three lines in one file for the conditional chain, and eight lines in two files for polymorphism.
  • The type name appears in three places in the conditional chain and one in the other two; with operation count N and type count T, the conditional chain carries N × T decisions, the table carries T rows.
  • Under polymorphism, the existing types’ file was never opened; the cost is a separate file for the new type and one line in the registry.
  • A table row is a fixed-shape data record; a type carries its own state. Three contract tariffs were produced from a single behavior definition, and the line count does not grow as the contract count grows.

Next Step

Throughout this topic, the decisions were about code’s internal structure: names, levels, format, branching, and option structure. What a function tells the outside has not yet been addressed. The tariff function throws an error for an unknown type, while the common response to the same problem is to return null. A signature that returns null tells the caller nothing, and the error shows up not where the gap was produced but where it was used. The next topic opens by measuring that distance: in a call chain that passes a null value along, how many calls later does the error become visible.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close