Forget Defensive Programming: Make Illegal States Impossible

Stop sprinkling if-guards and start encoding your domain’s rules directly in the types.

Stop sprinkling if-guards and start encoding your domain’s rules directly in the types.

Why “Defensive Programming” Is a Leaky Shield

When you wrap every public method in if-then checks, you’re treating symptoms, not the disease.

Defensive guards:

  • Repeat logic in many places — easy to miss one.
  • Let callers build bad data first and only react later.
  • Grow a forest of tests that merely prove you remembered the checks.

The better goal is eliminate the possibility of the bad state ever existing. If the compiler, type checker, or constructor forbids an illegal state, no run-time branch can “forget” to defend you.

Most teams practice defensive programming: peppering public methods with if statements, asserts, and “// should never happen” comments. Those checks catch bugs after bad data has already been constructed. They are easy to miss, easy to delete, and costly to test.

Better strategy Move the guardrails upstream so that the compiler (or a single constructor) forbids invalid states altogether. If it’s impossible to build bad data, no code path can ‘forget’ a check later.

The rest of this post walks through three common scenarios. For each one we start with the familiar defensive solution, explain its hidden risk, and then show a refactor that makes the illegal state unrepresentable.

Scenario 1 — Bank Withdrawals & Negative Balances

Context

A banking API exposes deposit() and withdraw() calls on an Account. Business says: an account balance can never go negative.

The common “defensive” version

public void withdraw(Account acc, double amount) { 
    if (amount <= 0) throw new IllegalArgumentException("Non-positive"); 
    if (amount > acc.getBalance()) throw new InsufficientFunds(); 
    acc.setBalance(acc.getBalance() - amount); 
}

Problem

Hidden risk — Every new path that mutates a balance (transfer(), nightly fees, SQL migration) must copy both guards. Miss one and the invariant collapses.

Solution: Make the balance invariant explicit

1 Wrap the primitive — introduce a Money value object that refuses negative values on construction.
2 Centralise rule enforcementAccount.withdraw() returns a sealed Result, forcing callers to handle failure.

// 1) Strong type = only valid money can exist 
public record Money(BigDecimal amount, Currency currency) { 
    public Money { 
        if (amount.signum() < 0) 
            throw new IllegalArgumentException("Negative money isn’t real"); 
        Objects.requireNonNull(currency); 
    } 
    public Money minus(Money other) { sameCurrency(other); return new Money(amount.subtract(other.amount), currency); } 
    // ...other helpers 
} 
// 2) Account cannot store an invalid balance 
public final class Account { 
    private Money balance = Money.zero("INR"); 
    public Result<Account> withdraw(Money amt) { 
        if (balance.lessThan(amt)) return Result.fail("Insufficient funds"); 
        balance = balance.minus(amt); 
        return Result.ok(this); 
    } 
}

What changed?

  • No caller can construct a negative amount — or mix currencies — because the Money constructor forbids it.
  • Every withdrawal explicitly yields Result; you cannot forget to handle failure.
  • The invariant lives in one place, not fifty ifs.

Scenario 2 — A Login State Machine in TypeScript

Context

A web SPA keeps a session object. Certain API calls are legal only after login completes.

Typical ad-hoc guard

let session = { token: undefined as string | undefined }; 
async function fetchData() { 
  if (!session.token) throw new Error("Not logged in"); 
  return http.get<Data>("/api/data", { headers: { Authorization: session.token } }); 
}

Hidden issue

Every new API helper must remember the same if (!token) guard.Make each legal state a distinct type

Solution: Encode the finite‑state machine in types

type Unauth = { kind: "unauth"; login(): Promise<Auth> }; 
type Auth   = { kind: "auth";  token: string; fetchData(): Promise<Data> }; 
type Session = Unauth | Auth; 
async function demo(): Promise<void> { 
  let s: Session = createSession();  // compiler: s is Unauth 
  s = await s.login();               // compiler: s is now Auth 
  const data = await s.fetchData();  // safe by construction 
}
  • It is syntactically impossible to call fetchData() too early because that method doesn’t even exist on the Unauth branch.

Scenario 3 — Feature Flags with Percentage Rollout

Context

Product wants progressive rollout: a flag can be disabled or enabled for N % of traffic (1–100).

The foot-gun version

interface Flag { enabled: boolean; percentage?: number } // percentage 0-100… we hope 
function rollout(flag: Flag) { 
  if (flag.enabled && Math.random() * 100 < (flag.percentage ?? 100)) { 
      enableFeature(); 
ty  } 
}

Bug factory: someone passes percentage: 150 in a config file, and half the world sees the feature 1.5×.

Solution: Lock it down with constrained ranges

// IntRange<1,100> is provided by a tiny compile-time plugin 
type Disabled = { kind: "disabled" }; 
type Enabled  = { kind: "enabled"; percentage: IntRange<1, 100> }; 
type Flag = Disabled | Enabled; 
function rollout(flag: Flag) { 
  if (flag.kind === "enabled" && shouldSample(flag.percentage)) enableFeature(); 
}
  • Impossible to create Enabled with 0 % or 101 % — the build fails
  • The branching logic is clearer because invalid inputs are gone.

Choosing Where Constraints Live

Not every rule can be encoded at compile‑time — stock levels fluctuate, user quotas change. But you can push many more invariants up the stack than you think.

Adopting the “Impossible States” Mindset

  1. Wrap a fragile primitive today — Email, Url, Money, Percentage.
  2. Replace boolean flags with expressive unions or enums.
  3. Seal class hierarchies (sealed interface Event …) so new subclasses are an explicit choice.
  4. Turn on a static checker (NullAway, TypeScript strict, Rust’s borrow checker).
  5. Refactor high‑risk modules first — payment, security, data import.

Each incremental step eliminates whole categories of tests and bug fixes. It’s “compound interest” for code quality: the earlier you strengthen the invariant, the more surface area benefits.

Conclusion — From Firefighting to Fireproofing

Defensive programming treats every API call as a potential disaster: “double‑check the inputs, hope we caught everything.” Over time the codebase accretes duplicated checks, test bloat, and the ever‑present risk that someone — somewhere — forgot an if.

Moving constraints into your types flips the game: illegal states become unrepresentable, not merely unlikely. The compiler’s guarantees replace scattered hope with structural certainty.

  • Fewer bugs ship — because many cannot compile.
  • Fewer tests are needed — you no longer prove the same guard 30 times.
  • On‑boarding speeds up — invariants live in constructors and sealed unions, not tribal knowledge.
Design code that can’t misbehave — then you don’t have to be defensive. Start small, strengthen one primitive, measure the calm that follows, and keep pushing the fence line outward. Future you (and your incident dashboard) will thank you.

Further Reading

Read more