Skip to content
aipatterns.dev

Refactoring techniqueOrganizing Data

Replace Type Code with State/Strategy

Move behavior that depends on a type code into separate state or strategy objects that the host holds and can swap at runtime.

Problem

A class branches on a type code in several methods, and the code changes during the object's life, or the class already has subclasses. Replace Type Code with Subclasses can't work, because an object cannot change its class.

Solution

Create an interface for the type-dependent behavior, one implementation per code, and give the host a field holding the current implementation. Changing the type code becomes replacing that object.

Before

TypeScript
class PurchaseOrder {
  status: "draft" | "submitted" | "approved" = "draft";
  lines: PoLine[] = [];

  addLine(line: PoLine): void {
    if (this.status !== "draft") throw new Error("only drafts can be edited");
    this.lines.push(line);
  }

  submit(): void {
    if (this.status !== "draft") throw new Error(`cannot submit from ${this.status}`);
    if (this.lines.length === 0) throw new Error("empty order");
    this.status = "submitted";
  }

  approve(): void {
    if (this.status !== "submitted") throw new Error(`cannot approve from ${this.status}`);
    this.status = "approved";
  }
}

After

TypeScript
interface PoState {
  readonly name: string;
  addLine(po: PurchaseOrder, line: PoLine): void;
  submit(po: PurchaseOrder): void;
  approve(po: PurchaseOrder): void;
}

const reject = (from: string, action: string) => () => {
  throw new Error(`cannot ${action} from ${from}`);
};

const Draft: PoState = {
  name: "draft",
  addLine: (po, line) => po.lines.push(line),
  submit: (po) => {
    if (po.lines.length === 0) throw new Error("empty order");
    po.state = Submitted;
  },
  approve: reject("draft", "approve"),
};

const Submitted: PoState = {
  name: "submitted",
  addLine: () => { throw new Error("only drafts can be edited"); },
  submit: reject("submitted", "submit"),
  approve: (po) => { po.state = Approved; },
};

const Approved: PoState = {
  name: "approved",
  addLine: () => { throw new Error("only drafts can be edited"); },
  submit: reject("approved", "submit"),
  approve: reject("approved", "approve"),
};

class PurchaseOrder {
  state: PoState = Draft;
  lines: PoLine[] = [];

  get status() { return this.state.name; }
  addLine(line: PoLine) { this.state.addLine(this, line); }
  submit() { this.state.submit(this); }
  approve() { this.state.approve(this); }
}

Why

  • Each status lists what it allows in one place. Adding "rejected" is a new object, not edits to every method.
  • The host keeps its identity while its behavior changes.
  • Works even if PurchaseOrder already has subclasses.

Steps

  1. Self-encapsulate the type code so reads and writes go through accessors.
  2. Define the state or strategy interface with one method per type-dependent operation.
  3. Create one implementation per code. Each starts by returning or throwing what the matching branch did.
  4. Add a field on the host for the current object. Make the type-code setter assign the matching implementation, and the getter read its name. Run the tests.
  5. For each method, move the branch bodies into the implementations and make the host delegate. Run the tests after each method.
  6. Remove the raw type-code field.

Watch out

  • State or Strategy? If implementations trigger transitions to each other, as here, it is State. If an outside caller picks one and it rarely changes, it is Strategy. The mechanics are the same.
  • Persist the state's name, not the object. Rebuild the state object when loading.
  • For two or three states with simple rules, a transition table (Record<Status, Status[]>) or a discriminated union may be clearer than objects.
  • If the type never changes and the class has no other subclasses, Replace Type Code with Subclasses is simpler.
  • Inverse: collapse states back into a code and conditionals when the variants shrink to trivial differences.

Agent checklist

  • The type changes at runtime, or the host already has subclasses.
  • The interface covers every operation that branched on the type.
  • Transitions happen in exactly one place per state.
  • Persistence stores the code and rebuilds the state on load.
  • Tests cover every allowed and forbidden transition.

Treats these smells

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.