Skip to content
academia.sh

Lesson 05 / 16

Reducing Cyclomatic Complexity

Measuring and reducing the number of branches: writing a measurer that counts decision points, comparing cyclomatic complexity between a nested and a simplified version of the fee calculation, and moving branching into data with guard clause and lookup table transformations.

Contents

Format decisions determine how code looks to the eye, not its structure. A properly indented function can still be hard to read: nested conditions, multiple exit points, and compound logical expressions do not get fixed by indentation. The measure of this difficulty is how many places the control flow splits into.

Cyclomatic complexity is that number. It gives the count of linearly independent paths through a function’s control flow graph, and it is computed with a single rule: the number of decision points, plus one. A decision point is any structure that splits the flow in two — if, for, while, case, catch, &&, ||, ??, and the ternary conditional. The number corresponds to two things: how many states a reader has to hold in mind while reading the function, and the lower bound on a test suite that covers every branch.

The Version Where Decisions Pile Into One Function

Over time the fee function took on three more jobs: input validation, a discount by customer type, and a coefficient by zone. All of them sit in the same function, nested.

mkdir -p complex simple
// complex/fee.mjs — validation, discount, and zone decisions nested together
const VOLUMETRIC_DIVISOR = 3000;

export function calculateFee(shipment, tariff, customer) {
  if (shipment.weightKg > 0) {
    if (shipment.widthCm > 0 && shipment.lengthCm > 0 && shipment.heightCm > 0) {
      const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
      const weight = shipment.weightKg > volumetric ? shipment.weightKg : volumetric;
      let fee = 0;
      for (const tier of tariff.weightTiers) {
        if (fee === 0 && weight <= tier.upperLimitKg) {
          fee = tier.ratePerKg * weight;
        }
      }
      if (fee === 0) {
        throw new RangeError("weight outside tier range");
      }
      if (customer.type === "contracted") {
        fee = fee * 0.85;
      } else if (customer.type === "corporate") {
        fee = fee * 0.92;
      }
      if (shipment.zoneCode === 3 || shipment.zoneCode === 4) {
        fee = fee * 1.8;
      } else if (shipment.zoneCode === 2) {
        fee = fee * 1.35;
      }
      return fee < tariff.minimumFee ? tariff.minimumFee : fee;
    } else {
      throw new RangeError("dimensions must be positive");
    }
  } else {
    throw new RangeError("weight must be positive");
  }
}

The Simplified Version

The second version applies three transformations. Guard clause: the validation conditions are inverted and moved to the top, invalid input is rejected early, and the actual calculation comes out from the bottom of the indentation. Lookup table: the discount and zone decisions move from a chain of conditionals into a data structure. Extraction: the remaining work moves into its own functions.

// simple/fee.mjs — same behavior, decisions moved to a table and guard clauses
const VOLUMETRIC_DIVISOR = 3000;
const DISCOUNT_RATES = { contracted: 0.15, corporate: 0.08 };
const ZONE_COEFFICIENTS = { 1: 1, 2: 1.35, 3: 1.8, 4: 1.8 };

function validateDimensions(shipment) {
  if (shipment.weightKg <= 0) throw new RangeError("weight must be positive");
  const dimensions = [shipment.widthCm, shipment.lengthCm, shipment.heightCm];
  if (dimensions.some((d) => d <= 0)) throw new RangeError("dimensions must be positive");
}

function billableWeightKg(shipment) {
  const volumetric = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR;
  return Math.max(shipment.weightKg, volumetric);
}

function tierFee(weightKg, tariff) {
  const tier = tariff.weightTiers.find((t) => weightKg <= t.upperLimitKg);
  if (tier === undefined) throw new RangeError("weight outside tier range");
  return tier.ratePerKg * weightKg;
}

export function calculateFee(shipment, tariff, customer) {
  validateDimensions(shipment);
  const weight = billableWeightKg(shipment);
  const discount = 1 - (DISCOUNT_RATES[customer.type] ?? 0);
  const coefficient = ZONE_COEFFICIENTS[shipment.zoneCode] ?? 1;
  return Math.max(tierFee(weight, tariff) * discount * coefficient, tariff.minimumFee);
}

For the comparison to mean anything, both versions are shown to keep the same behavior. Seven cases are checked: four valid calculations and three errors.

// compare.mjs — verifies both versions give the same result on the same inputs
import { calculateFee as complex } from "./complex/fee.mjs";
import { calculateFee as simple } from "./simple/fee.mjs";

const tariff = {
  weightTiers: [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 },
    { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: 30, ratePerKg: 17 }],
  minimumFee: 52,
};
const box = { widthCm: 30, lengthCm: 24, heightCm: 18 };
const CASES = [
  ["contracted, zone 2", { ...box, weightKg: 2.4, zoneCode: 2 }, "contracted"],
  ["corporate, zone 3", { ...box, weightKg: 6, zoneCode: 3 }, "corporate"],
  ["individual, zone 1", { widthCm: 10, lengthCm: 10, heightCm: 10, weightKg: 0.4, zoneCode: 1 }, "individual"],
  ["corporate, zone 4", { ...box, weightKg: 12, zoneCode: 4 }, "corporate"],
  ["zero weight", { ...box, weightKg: 0, zoneCode: 1 }, "individual"],
  ["zero width", { ...box, widthCm: 0, weightKg: 2, zoneCode: 1 }, "individual"],
  ["outside tier range", { ...box, weightKg: 40, zoneCode: 1 }, "individual"],
];

const result = (fn, shipment, type) => {
  try {
    return fn(shipment, tariff, { type }).toFixed(2);
  } catch (error) {
    return `error: ${error.message}`;
  }
};

for (const [name, shipment, type] of CASES) {
  const a = result(complex, shipment, type);
  const b = result(simple, shipment, type);
  console.log(`${name.padEnd(20)} ${a === b ? "same  " : "DIFFER"} ${a}`);
}
contracted, zone 2   same   128.89
corporate, zone 3    same   208.66
individual, zone 1   same   52.00
corporate, zone 4    same   337.82
zero weight          same   error: weight must be positive
zero width           same   error: dimensions must be positive
outside tier range   same   error: weight outside tier range

The Measurer

The measurer splits the source into functions, counts decision-point patterns in each body, and gives complexity as the decision count plus one. The module row is the sum of the values computed per function.

// complexity-measure.mjs — counts each function's decision points and cyclomatic complexity
import { readFileSync } from "node:fs";

// Every structure that branches the control flow is a decision point.
const DECISION = [
  ["if", /\bif\s*\(/g], ["for", /\bfor\s*\(/g], ["while", /\bwhile\s*\(/g],
  ["case", /\bcase\s+/g], ["catch", /\bcatch\s*\(/g],
  ["&&", /&&/g], ["||", /\|\|/g], ["??", /\?\?/g], ["?:", /\?(?!\?)[^:\n]*:/g],
];

function functions(source) {
  const output = [];
  let open = null, depth = 0;
  for (const line of source.split("\n")) {
    if (open === null) {
      const m = line.match(/^(?:export\s+)?function\s+(\w+)/);
      if (m === null) continue;
      [open, depth] = [{ name: m[1], body: [] }, 0];
    }
    open.body.push(line);
    depth += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
    if (depth === 0) { output.push({ name: open.name, body: open.body.join("\n") }); open = null; }
  }
  return output;
}

for (const file of process.argv.slice(2)) {
  console.log(file);
  let total = 0;
  for (const { name, body } of functions(readFileSync(file, "utf8"))) {
    const counts = DECISION.map(([label, pattern]) => [label, (body.match(pattern) ?? []).length]);
    const decisions = counts.reduce((t, [, n]) => t + n, 0);
    const breakdown = counts.filter(([, n]) => n > 0).map(([l, n]) => `${l}:${n}`).join(" ");
    total += decisions + 1;
    console.log(`  ${name.padEnd(24)} decisions=${String(decisions).padEnd(3)}M=${String(decisions + 1).padEnd(3)}${breakdown || "-"}`);
  }
  console.log(`  ${"= module".padEnd(24)} total M=${total}`);
}
node complexity-measure.mjs complex/fee.mjs simple/fee.mjs
complex/fee.mjs
  calculateFee             decisions=15 M=16 if:8 for:1 &&:3 ||:1 ?::2
  = module                 total M=16
simple/fee.mjs
  validateDimensions       decisions=2  M=3  if:2
  billableWeightKg         decisions=0  M=1  -
  tierFee                  decisions=1  M=2  if:1
  calculateFee             decisions=2  M=3  ??:2
  = module                 total M=9

Fifteen decision points piled into a single function mean sixteen independent paths. In the simplified version, the highest value is three and the module total drops to nine. The two numbers say different things: the module total says how many branches exist in the work as a whole, the per-function value says how much of it one person has to hold in mind at once.

Branches in the Same Function Multiply

Why can the total drop to nine? Part of it comes from branching moving into data: the discount chain went from two conditions to two table rows, the zone chain from two conditions to four table rows. The rest of the gain comes from the split itself.

Branches sitting in the same function multiply. In the complex version, once validation passes, the discount gives three outcomes (contracted, corporate, or neither), the zone gives three outcomes (distant, neighboring, or same-city), and the minimum fee comparison gives two outcomes. Since these are independent of each other, the number of combinations that determine how the function ends is 3 × 3 × 2 = 18, and all of them sit inside a single body.

When the same three decisions are distributed across separate functions, the combination count adds: each function is tested separately with its own branches, because the only connection between them is passing a single value along. The table transformation carries this further: DISCOUNT_RATES and ZONE_COEFFICIENTS are data rows, so they produce no branches at all. Adding a new customer type adds an else if branch to the complex version and increases its complexity by one; it adds a table row to the simplified version and does not increase complexity at all.

What the Number Does Not Say

Cyclomatic complexity has limits, and using it without knowing them produces the wrong decisions.

The number and clarity are not the same thing. A long switch block produces a high value but reads at a glance; a single condition inside two nested loops produces a lower value and is harder to follow. The measure counts how many ways the flow splits; it does not count how similar the resulting paths are to each other.

The number is lowered by splitting, but splitting is not free. Code that distributes fifteen decision points across fifteen functions is spotless in the measure’s eyes and hard to read: the reader now has to follow a chain of calls instead. This is where the previous lesson’s measure comes back in: split points are chosen by abstraction level, not to lower the number.

A high value is not a defect, it is a signal. When the measurement flags a function, the question is not “how should this be split,” it is “should all of these decisions really be made here.” The complex version’s eight if statements were an example of this: three belonged to validation, two to the discount, two to the zone, and none of them was the fee calculation itself.

Summary

  • Cyclomatic complexity is the decision count plus one, and it gives the number of independent paths through a function’s control flow; decision points are if, for, while, case, catch, &&, ||, ??, and the ternary conditional.
  • In the nested version, a single function carried fifteen decision points and M = 16; after the guard clause, lookup table, and extraction transformations, the highest function value was 3 and the module total was 9.
  • Both versions produce the same result in all seven cases; the measurement was done without changing behavior.
  • Independent branches in the same body multiply (3 × 3 × 2 = 18 combinations); split across separate functions they add; a decision moved into a table produces no branch at all.
  • The measure counts how many ways the flow splits, not how clear the paths are; a high value points not to a place that needs splitting but to a pile of decisions that needs questioning.

Next Step

Zone coefficients moved from a chain of conditionals into a table, and complexity dropped. Is this transformation always correct? A table returns a value for a key; what if the counterpart is not a value but a behavior, or if every tariff type computes its fee by a different method entirely? At that point a third option appears: polymorphism. The next lesson writes three versions of the same behavior — conditional chain, lookup table, and polymorphism — and ties the choice to a measurable question: how many files and how many lines get touched when a new tariff type is added.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close