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
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
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
PurchaseOrderalready has subclasses.
Steps
- Self-encapsulate the type code so reads and writes go through accessors.
- Define the state or strategy interface with one method per type-dependent operation.
- Create one implementation per code. Each starts by returning or throwing what the matching branch did.
- 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. - For each method, move the branch bodies into the implementations and make the host delegate. Run the tests after each method.
- 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.
Related
- Fixes Switch Statements, Primitive Obsession and Large Class.
- Patterns: State and Strategy.
- Siblings: Replace Type Code with Class and Replace Type Code with Subclasses.
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
- Switch StatementsThe same switch or if-chain over a type code is repeated across the codebase, so adding one case means editing every copy.
- Primitive ObsessionDomain concepts like money, emails, IDs or statuses are passed around as raw strings and numbers, so their rules get re-checked everywhere.
- Large ClassA class with too many fields, methods or responsibilities, so every change risks breaking something unrelated inside it.
See also
- Replace Type Code with SubclassesTurn each value of an immutable type code that drives conditionals into a subclass, and move the branch logic into overrides.
- Replace Type Code with ClassReplace a set of string or number codes that don't affect behavior with a dedicated type, so only valid codes can be used.
- Replace Conditional with PolymorphismReplace a switch on an object's type or kind with one class per case that overrides a shared method, so new cases add code instead of editing it.
- StateMove each mode of an object into its own class so behavior and transitions change with the mode instead of branching on a status field.
- StrategyPut each interchangeable algorithm behind one interface so the caller picks a behavior at runtime without branching on it.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.