An integer or string field encodes a domain concept; every consumer of the field knows the magic values and branches on them. New status values land as new magic literals; comparisons duplicate across the codebase; the type system treats `status === 2` and `status === 'shipped'` as equally valid.
The type code becomes a value object class with named instances. Comparisons become method calls (`status.canCancel()`); new statuses land as new instances + tests; the type system distinguishes order statuses from arbitrary integers.
Before the refactoring
// Status is an integer; every consumer comparisons it against magic literals.class Order {constructor() {this.status = 0; // 0=pending, 1=paid, 2=shipped, 3=delivered, 4=cancelled}describe() {if (this.status === 0) return 'Pending';if (this.status === 1) return 'Paid';if (this.status === 2) return 'Shipped';if (this.status === 3) return 'Delivered';if (this.status === 4) return 'Cancelled';return 'Unknown';}canCancel() {return this.status === 0 || this.status === 1;}}
After the refactoring
// Status is a typed value object; comparisons and behaviour live on the class.class OrderStatus {static PENDING = new OrderStatus('Pending', true);static PAID = new OrderStatus('Paid', true);static SHIPPED = new OrderStatus('Shipped', false);static DELIVERED = new OrderStatus('Delivered', false);static CANCELLED = new OrderStatus('Cancelled', false);constructor(label, cancellable) {this.label = label;this.cancellable = cancellable;}describe() { return this.label; }canCancel() { return this.cancellable; }}class Order {constructor() {this.status = OrderStatus.PENDING;}describe() { return this.status.describe(); }canCancel() { return this.status.canCancel(); }}
Magic-literal type codes are Primitive Obsession at scale. Bugs ship as 'used 2 when 3 was intended' or 'compared status to lowercase 'shipped' when the convention is uppercase'. The compiler/runtime cannot help; tests and code review carry the full enforcement burden.
A value object class is one more domain class to learn; serializing/deserializing requires explicit translation between the primitive and the value object form. For status fields that flow through many database round-trips, the translation boundary is real work.
Comparisons become typed method calls; new statuses are one named instance with one row of test coverage; consumers don't need to know the underlying primitive at all. Adding behaviour to a status (e.g., 'is terminal?') is one method on the class.
If the value object simply wraps the primitive without offering methods (no `describe`, no `canCancel`, no `isTerminal`), it adds a layer without buying anything. The pattern earns its keep when the type code carries behaviour worth promoting — not when it's just a name for the primitive.