---
title: 'Reducing Cyclomatic Complexity'
source: 'https://academia.sh/en/courses/clean-code/reducing-cyclomatic-complexity'
course: 'Clean Code'
language: en
updated: '2026-08-23T07:01:12+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```sh
mkdir -p complex simple
```

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

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

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

```js
// 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}`);
}
```

```sh
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.
