enum-driven-state-refactoring
Refactor loose state models (optional fields, parallel booleans) into strict sum types. Apply when a field's validity depends on another field's value.
When & Why to Use This Skill
This Claude skill specializes in refactoring loose state models into strict sum types to ensure type safety and eliminate invalid program states. By implementing the 'Make Illegal States Unrepresentable' principle, it replaces error-prone optional fields and parallel booleans with robust structures like discriminated unions and sealed classes, significantly reducing runtime bugs and improving code maintainability.
Use Cases
- Refactoring asynchronous data structures (e.g., Loading/Success/Error) to ensure that data is only accessible when the state is explicitly 'Success'.
- Replacing multiple independent boolean flags (e.g., is_pending, is_running, is_complete) with a single exhaustive enum to prevent logical contradictions and 'impossible' states.
- Redesigning API DTOs and domain models to use tagged unions, ensuring that specific fields are strictly coupled with their respective status indicators.
- Improving code reliability by forcing exhaustive pattern matching, which ensures that all possible state variants are handled by the compiler during future updates.
- Eliminating defensive null checks and runtime validation by pushing state constraints into the type system (Parse, don't validate).
| name | enum-driven-state-refactoring |
|---|---|
| description | Refactor loose state models (optional fields, parallel booleans) into strict sum types. Apply when a field's validity depends on another field's value. |
Enum-Driven State
A behavior-preserving refactor that makes unintended states structurally impossible using Sum Types (Enums, Discriminated Unions, Sealed Classes).
Core Principles
- Make Illegal States Unrepresentable: Structure types so invalid combinations cannot compile—no runtime validation or comments needed.
- Sum Types over Product Types: Replace structs with optional fields (
status,data?,error?) with enums where each variant holds exactly what it needs. - Pattern Matching: Replace boolean checks (
if isX) with exhaustivematch/switch. Adding a new state forces compiler errors at all call sites. - Parse, Don't Validate: Push checks to the boundary. Once an object exists, it is guaranteed valid.
- Co-location of Data: Data valid only in a specific state belongs inside that state's definition, not as an optional sibling.
Quick Decision
| If you see... | Then consider... |
|---|---|
isX && isY boolean checks |
Sum type with exclusive variants |
data?: T with error?: E |
Result-style union |
status + nullable siblings |
Discriminated union with co-located data |
| Field validity depends on another field | Move data inside the relevant case |
get isX() { return type === 'x' } |
You already have a discriminator—formalize it |
Detection Heuristics
Smell: Conditional Optionality
// 🚩 Field optionality depends on another field's value
interface Request {
status: string;
response?: Data; // "present if status is 'success'"
error?: Error; // "present if status is 'error'"
retryCount?: number; // "only tracked if status is 'error'"
}
// If a field is only valid when another field has a specific value,
// it belongs INSIDE that state, not as an optional sibling.
Smell: Parallel Booleans
# 🚩 N booleans = 2^N possible states, most invalid
class Task:
is_pending: bool
is_running: bool
is_complete: bool
is_failed: bool
Smell: Defensive Nil Checks
// 🚩 Checking nullability after checking discriminator
if self.status == Status::Success {
if let Some(data) = &self.data { // Why is this optional?
process(data);
}
}
Smell: Temporal Coupling
// 🚩 Field validity depends on method call order
conn := &Connection{} // socket is nil
conn.Connect() // now socket is valid
conn.Send(data) // crashes if Connect() wasn't called
Language Patterns
| Language | Construct | Example |
|---|---|---|
| Rust | enum with variants |
enum Result<T,E> { Ok(T), Err(E) } |
| Swift | enum with associated values |
case .loaded(Data) |
| TypeScript | Discriminated union | { status: 'ok', data: T } | { status: 'err', error: E } |
| Python 3.10+ | Union of dataclasses |
CreditCard | PayPal with match |
| Kotlin | sealed interface |
sealed interface State with data class impls |
| Java 17+ | sealed interface + record |
sealed interface State permits Loading, Success |
| Go | Interface + unexported marker | Or struct with discriminator + exclusive pointers |
Refactor Procedure
1. MAP STATES
└─ List all valid (state, required_data) pairs
└─ Identify mutually exclusive states
2. GROUP DATA
└─ For each state: which fields are REQUIRED?
└─ Move those fields INTO the state definition
3. DEFINE SUM TYPE
└─ One variant per valid state
└─ Each variant contains only its required data
4. UPDATE CALL SITES
└─ Replace `if (obj.field != null)` with pattern match
└─ Compiler errors guide you to all locations
5. DELETE DEAD CODE
└─ Remove runtime invariant checks
└─ Remove defensive null guards
└─ Remove boolean flag logic
Canonical Examples
Async State (TypeScript)
// ❌ Before: 4 fields × optional = ambiguous
interface State<T> {
status: 'idle' | 'loading' | 'success' | 'error';
data?: T;
error?: Error;
}
// ✅ After: exactly 4 valid shapes
type State<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
Connection Config (Rust)
// ❌ Before: ssh_key meaningless for HTTP
struct Config {
protocol: String,
url: String,
ssh_key: Option<String>,
}
// ✅ After: each variant has exactly what it needs
enum Connection {
Http { url: String, port: u16 },
Ssh { host: String, key: PathBuf },
Unix { path: PathBuf },
}
Payment Method (Python)
# ❌ Before: which fields go with which type?
@dataclass
class Payment:
type: str
cc_number: str | None = None
paypal_email: str | None = None
# ✅ After: structural pattern matching
@dataclass
class CreditCard:
number: str
cvv: str
@dataclass
class PayPal:
email: str
Payment = CreditCard | PayPal
def process(p: Payment):
match p:
case CreditCard(number=n): charge(n)
case PayPal(email=e): invoice(e)
Boundaries (When to Stop)
| Situation | Guidance |
|---|---|
| DTOs / DB models | Keep flat for serialization; convert to sum type at domain boundary |
| Cross-cutting fields | Keep id, timestamp, etc. on wrapper: struct Event { id, payload: EventKind } |
| Large variant data | Extract to dedicated struct: case Success(SuccessPayload) |
| Serialization | Configure tagged union support (Serde #[serde(tag)], Zod discriminatedUnion, etc.) |
| Trivial flags | A single boolean is_enabled doesn't need a sum type |
Why This Works (Cardinality)
Product types multiply → most combinations invalid Sum types add → only valid combinations exist
Product: status × data? × error? = 3 × 2 × 2 = 12 states (8 invalid)
Sum: Idle + Loading + Success(T) + Error(E) = 4 states (0 invalid)