---
title: State
source: 'https://academia.sh/en/courses/design-patterns/state'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:13+00:00'
license: 'CC BY-SA 4.0'
---

# State

Comparing keeping the shipment life cycle in five flags and nested conditionals with turning each state into an object that knows its own transitions: the number of invalid transitions accepted across thirty state–event pairs, cyclomatic complexity, the number of representable combinations, the number of lines changed when a new state is added, and the pattern's cost as a delayed appearance of a transition-target error.

In the template, the order was fixed; which step would run was known from the start. In the
shipment itself, which operation is valid depends on the current state. A shipment can be
created, collected, in transit, out for delivery, delivered, or returned; a shipment that has
not been collected cannot go out for delivery, and a delivered shipment cannot be returned.
Today, these rules are checked with five flags and nested conditionals.

The state machine concept was established once before, in the Software Quality and Testing
curriculum: the Error Life Cycle lesson described it there with a state and transition table,
and the transitions were used to produce test cases. What is new here is not the machine
itself but **its expression through objects**. The **State** pattern turns each state into an
object and tells that object only the transitions it permits. The numbers to measure are the
number of accepted invalid transitions, cyclomatic complexity, and the number of representable
flag combinations.

## The Life Cycle Held in Flags

There are five flags and five events. Each event sets up its own condition; the state's name
is derived from checking the flags in sequence.

```sh
mkdir -p flags state
```

```js
// flags/shipment.mjs — state kept in five flags, transitions in nested conditionals
export const shipment = (code) => {
  const d = { collected: false, inTransit: false, outForDelivery: false, delivered: false, returned: false };
  const stateName = () =>
    d.returned ? "returned" : d.delivered ? "delivered" : d.outForDelivery ? "out_for_delivery" : d.inTransit ? "in_transit" : d.collected ? "collected" : "created";
  return {
    code,
    stateName,
    event(name) {
      if (name === "collect") {
        if (d.collected === false && d.delivered === false) {
          d.collected = true;
          return true;
        }
        return false;
      }
      if (name === "depart") {
        if (d.collected && d.inTransit === false) {
          d.inTransit = true;
          return true;
        }
        return false;
      }
      if (name === "outForDelivery") {
        if (d.inTransit && d.outForDelivery === false) {
          d.outForDelivery = true;
          return true;
        }
        return false;
      }
      if (name === "deliver") {
        if (d.inTransit && d.delivered === false && d.returned === false) {
          d.delivered = true;
          return true;
        }
        return false;
      }
      if (name === "return") {
        if (d.inTransit && d.returned === false) {
          d.returned = true;
          return true;
        }
        return false;
      }
      return false;
    },
  };
};
```

## Turning the State into an Object

In the second version, each state is an object and carries its own set of transitions. The
transition set is a mapping from an event name to the name of the state that follows it.

```js
// state/states.mjs — each state knows only its own transitions
const state = (name, transitions) => ({
  name,
  allows: (event) => Object.hasOwn(transitions, event),
  next: (event) => transitions[event],
});

export const STATES = {
  created: state("created", { collect: "collected" }),
  collected: state("collected", { depart: "in_transit" }),
  in_transit: state("in_transit", { outForDelivery: "out_for_delivery", return: "returned" }),
  out_for_delivery: state("out_for_delivery", { deliver: "delivered", return: "returned" }),
  delivered: state("delivered", {}),
  returned: state("returned", {}),
};
```

```js
// state/shipment.mjs — the transition decision is asked of the state object
import { STATES } from "./states.mjs";

export const shipment = (code) => {
  let current = STATES.created;
  return {
    code,
    stateName: () => current.name,
    event(name) {
      if (current.allows(name) === false) return false;
      current = STATES[current.next(name)];
      return true;
    },
  };
};
```

The shipment object no longer knows which event is valid in which state; it asks the state
object it holds for the decision. This is the state-machine counterpart of the tell, don't ask
principle: the knowledge sits where the decision is made.

## Trying All Thirty Pairs

The measurement is done by trying all five events in each of the six states: thirty pairs. The
expectation table and the path leading to each state sit in a separate file.

```js
// table.mjs — valid transition table and the path to reach each state
export const EVENTS = ["collect", "depart", "outForDelivery", "deliver", "return"];
export const PATH = {
  created: [],
  collected: ["collect"],
  in_transit: ["collect", "depart"],
  out_for_delivery: ["collect", "depart", "outForDelivery"],
  delivered: ["collect", "depart", "outForDelivery", "deliver"],
  returned: ["collect", "depart", "return"],
};
export const VALID = {
  created: { collect: "collected" },
  collected: { depart: "in_transit" },
  in_transit: { outForDelivery: "out_for_delivery", return: "returned" },
  out_for_delivery: { deliver: "delivered", return: "returned" },
  delivered: {},
  returned: {},
};
```

```js
// run.mjs — tries all thirty state-event pairs in both versions
import { shipment as flagShipment } from "./flags/shipment.mjs";
import { shipment as stateShipment } from "./state/shipment.mjs";
import { VALID, EVENTS, PATH } from "./table.mjs";

function probe(create, label) {
  let acceptedInvalid = 0;
  let rejectedValid = 0;
  const rows = [];
  for (const [stateName, path] of Object.entries(PATH)) {
    const marks = [];
    for (const event of EVENTS) {
      const t = create("TS-1");
      for (const e of path) t.event(e);
      const accepted = t.event(event);
      const valid = Object.hasOwn(VALID[stateName], event);
      if (accepted && valid === false) acceptedInvalid += 1;
      if (accepted === false && valid) rejectedValid += 1;
      marks.push(accepted === valid ? "." : accepted ? "A" : "R");
    }
    rows.push(`  ${stateName.padEnd(17)} ${marks.join(" ")}`);
  }
  console.log(`${label} (${EVENTS.join(" ")}):`);
  for (const r of rows) console.log(r);
  console.log(`  ${label.padEnd(17)} accepted invalid=${acceptedInvalid}  rejected valid=${rejectedValid}`);
}

probe(flagShipment, "flags");
probe(stateShipment, "state");
```

```
flags (collect depart outForDelivery deliver return):
  created           . . . . .
  collected         . . . . .
  in_transit        . . . A .
  out_for_delivery  . . . . .
  delivered         . . . . A
  returned          . . A . .
  flags             accepted invalid=3  rejected valid=0
state (collect depart outForDelivery deliver return):
  created           . . . . .
  collected         . . . . .
  in_transit        . . . . .
  out_for_delivery  . . . . .
  delivered         . . . . .
  returned          . . . . .
  state             accepted invalid=0  rejected valid=0
```

Three versus zero. The flag version rejects no valid transition, so it is not broadly broken;
it **allows too much**. The three `A` marks are three concrete bugs: a shipment that has not
gone out for delivery can be marked delivered, a delivered shipment can be returned, and a
returned shipment can still be handed out for delivery. All three share the same source: every
event sets up its own condition, and none of them asks "what state am I in right now" from a
single place.

## Complexity and the Representation Space

```js
// complexity.mjs — cyclomatic complexity by counting decision points
import { readFileSync, readdirSync } from "node:fs";

const DECISION = /\bif\b|&&|\|\||\?|\bcase\b|\bwhile\b|\bfor\b/g;

for (const dir of ["flags", "state"]) {
  let decisions = 0;
  let lines = 0;
  const files = readdirSync(dir).sort();
  for (const f of files) {
    const text = readFileSync(`${dir}/${f}`, "utf8").replace(/^\/\/.*$/gm, "");
    decisions += (text.match(DECISION) ?? []).length;
    lines += text.split("\n").filter((s) => s.trim().length > 0).length;
  }
  console.log(`${dir.padEnd(7)} files=${files.length}  lines=${lines}  decision points=${decisions}  cyclomatic complexity=${decisions + 1}`);
}
```

```
flags   files=1  lines=47  decision points=21  cyclomatic complexity=22
state   files=2  lines=26  decision points=1  cyclomatic complexity=2
```

Twenty-two versus two. The difference comes from moving decisions from code to data: the
transition rules are no longer branches but map entries. The second number shows why this
leaves no room for error.

```js
// cost.mjs — representable combinations and the delay from a mistyped transition target
import { cpSync, readFileSync, writeFileSync } from "node:fs";
import { STATES } from "./state/states.mjs";

const FLAGS = 5;
console.log(`flags: representable combinations=${2 ** FLAGS}  valid states=${Object.keys(STATES).length}`);
console.log(`state: representable combinations=${Object.keys(STATES).length}  valid states=${Object.keys(STATES).length}`);

cpSync("state", "state-broken", { recursive: true });
writeFileSync(
  "state-broken/states.mjs",
  readFileSync("state/states.mjs", "utf8").replace('outForDelivery: "out_for_delivery"', 'outForDelivery: "out_for_deliveryy"'),
);

const t = (await import("./state-broken/shipment.mjs")).shipment("TS-3");
let eventNo = 0;
for (const e of ["collect", "depart", "outForDelivery", "deliver"]) {
  eventNo += 1;
  try {
    const accepted = t.event(e);
    console.log(`event ${eventNo} ${e.padEnd(15)} accepted=${accepted ? 1 : 0}`);
  } catch (err) {
    console.log(`event ${eventNo} ${e.padEnd(15)} ${err.constructor.name} thrown`);
    break;
  }
}
```

```
flags: representable combinations=32  valid states=6
state: representable combinations=6  valid states=6
event 1 collect         accepted=1
event 2 depart          accepted=1
event 3 outForDelivery  accepted=1
event 4 deliver         TypeError thrown
```

Thirty-two versus six. Five independent flags produce thirty-two combinations, and twenty-six
of them correspond to no real state; being out for delivery without being in transit, or being
both delivered and returned, are representable. Because the state object holds a single
variable, its representation space equals the set of valid states.

The output's last four lines give the first item of the cost. When a transition target is
misspelled by one letter, the `outForDelivery` event is **accepted**, and the error surfaces
only on the next event. Because transitions are data, target names are not code, and a
misspelled name breaks not where it was written but where it is used. The second item of cost
is the file count: one file versus two, and understanding whether an event is accepted or not
requires reading both.

## Adding a New State

An `on_hold` state and two new events are requested for shipments awaiting address
confirmation: an in-transit shipment is held with `hold` and set back in motion with `resume`.
The script applies the change to both versions, counts the changed lines, and runs the same
event sequence in both.

```js
// new-state.mjs — adds the "on_hold" state to both versions and measures the change
import { cpSync, readFileSync, writeFileSync } from "node:fs";

cpSync("flags", "flags-new", { recursive: true });
cpSync("state", "state-new", { recursive: true });

const edit = (path, search, next) => {
  const before = readFileSync(path, "utf8");
  writeFileSync(path, before.replace(search, next));
};

edit(
  "flags-new/shipment.mjs",
  "  const d = { collected: false, inTransit: false, outForDelivery: false, delivered: false, returned: false };",
  "  const d = { collected: false, inTransit: false, outForDelivery: false, delivered: false, returned: false, onHold: false };",
);
edit(
  "flags-new/shipment.mjs",
  '    d.returned ? "returned" : d.delivered ? "delivered" : d.outForDelivery ? "out_for_delivery" : d.inTransit ? "in_transit" : d.collected ? "collected" : "created";',
  '    d.returned ? "returned" : d.delivered ? "delivered" : d.onHold ? "on_hold" : d.outForDelivery ? "out_for_delivery" : d.inTransit ? "in_transit" : d.collected ? "collected" : "created";',
);
edit(
  "flags-new/shipment.mjs",
  '      if (name === "outForDelivery") {\n        if (d.inTransit && d.outForDelivery === false) {',
  `      if (name === "hold") {
        if (d.inTransit && d.onHold === false && d.outForDelivery === false) {
          d.onHold = true;
          return true;
        }
        return false;
      }
      if (name === "resume") {
        if (d.onHold) {
          d.onHold = false;
          return true;
        }
        return false;
      }
      if (name === "outForDelivery") {
        if (d.inTransit && d.outForDelivery === false && d.onHold === false) {`,
);

edit(
  "state-new/states.mjs",
  '  in_transit: state("in_transit", { outForDelivery: "out_for_delivery", return: "returned" }),',
  `  in_transit: state("in_transit", { outForDelivery: "out_for_delivery", return: "returned", hold: "on_hold" }),
  on_hold: state("on_hold", { resume: "in_transit", return: "returned" }),`,
);

const diffLines = (a, b) => {
  const x = readFileSync(a, "utf8").split("\n");
  const y = readFileSync(b, "utf8").split("\n");
  const set = new Set(x.map((s) => s.trim()));
  return y.filter((s) => s.trim().length > 0 && set.has(s.trim()) === false).length;
};

console.log(`flags: lines added/changed=${diffLines("flags/shipment.mjs", "flags-new/shipment.mjs")}`);
console.log(`state: lines added/changed=${diffLines("state/states.mjs", "state-new/states.mjs")}`);

const b = (await import("./flags-new/shipment.mjs")).shipment("TS-2");
const c = (await import("./state-new/shipment.mjs")).shipment("TS-2");
for (const t of [b, c]) {
  const trail = [];
  for (const e of ["collect", "depart", "hold", "outForDelivery", "resume", "outForDelivery", "deliver"]) {
    trail.push(`${e}=${t.event(e) ? "1" : "0"}`);
  }
  console.log(`${t.stateName().padEnd(10)} ${trail.join(" ")}`);
}
```

```
flags: lines added/changed=9
state: lines added/changed=2
delivered  collect=1 depart=1 hold=1 outForDelivery=0 resume=1 outForDelivery=1 deliver=1
delivered  collect=1 depart=1 hold=1 outForDelivery=0 resume=1 outForDelivery=1 deliver=1
```

Nine lines versus two lines, and both versions give the same sequence of acceptances and the
same final state for the same event sequence. Part of the nine lines goes into an existing
condition — a third check was added to the `outForDelivery` condition — so the new state also
puts the existing transitions' behavior at risk. In the state version, none of the existing
transition lines were touched.

## Summary

- The State pattern turns each state into an object that knows its own transition set; the
  shipment object does not know which event is valid, it asks the state object it holds.
- Trying all thirty state–event pairs, the flag version accepted 3 invalid transitions and the
  state version accepted 0; neither version rejected any valid transition.
- Cyclomatic complexity dropped from 22 to 2; the number of representable combinations dropped
  from 32 to 6, eliminating 26 meaningless flag combinations.
- Adding a new state and two events changed 9 lines in the flag version and 2 lines in the
  state version; both versions gave the same result for the same event sequence.
- Cost: the file count rose from 1 to 2, and a misspelled transition target surfaced not on
  the event where it was written but on the next one.

## Next Step

The state object answers one question from a single place: is this event valid here. On the
library's fee-correction side, the question cannot be asked in one place. A
contracted-customer discount, a volume discount, a campaign discount, and a manually entered
adjustment can be applied to a shipment in sequence; some of the rules do not fit the shipment
and are skipped, and some cut the chain short once applied. Today, these rules sit in a single
body with nested conditionals, and their order is buried inside that body. The next lesson
counts this body's decision points, measures the lines edited when the rule order changes,
then builds a chain that passes the request between handlers and recalculates the same
numbers.
