A behavior-preserving refactor that makes unintended states structurally impossible using Sum Types (Enums, Discriminated Unions, Sealed Classes).
- 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.
| 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 |
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: boolSmell: 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 | 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 |
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
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)| 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 |
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)