01 Illegal states unrepresentable 6 chapters

Theme 01 · Type-Safety Maximalist, But Pragmatic

Illegal states unrepresentable

Explanation

Illegal states unrepresentable

Plain Human Explanation

The safest code is code that cannot describe a broken product state in the first place. If a subscription is cancelled, the model should not also let it say “active forever.” If a trial has expired, the shape should make that visible instead of hiding it behind a loose flag.

Technical Explanation

In TypeScript, model meaningful product cases as separate variants instead of one broad object with optional fields and booleans. A discriminated union is the usual tool: each case has a stable tag like kind: "active" and only the fields that make sense for that case. Functions then accept and return those variants, so invalid combinations fail during development instead of becoming cleanup work later.

Why It Matters

  • User impact: people see the access and billing behavior the product promised.
  • Product behavior: important states such as trial, active, past-due, and cancelled stay distinct.
  • Risk: broad records can silently combine fields that should never exist together, such as cancelledAt plus renewsAt.
  • Decision point: use this when a bad combination would affect access, money, data integrity, or support clarity.

The Core Move

Replace “one object that can mean anything” with named cases that only contain valid fields. The model should guide the next developer toward legal transitions and make impossible combinations awkward or unrepresentable.

Small Example

Illegal states unrepresentable: Small Example

Bad TypeScript Example

type Subscription = {
  userId: string;
  status: "trial" | "active" | "cancelled";
  trialEndsAt?: Date;
  renewsAt?: Date;
  cancelledAt?: Date;
};

export function canUseProFeatures(subscription: Subscription, now: Date) {
  if (subscription.status === "cancelled") return false;
  if (subscription.trialEndsAt && subscription.trialEndsAt > now) return true;
  return subscription.renewsAt !== undefined;
}
type Subscription = {
  userId: string;
  status: "trial" | "active" | "cancelled";
  trialEndsAt?: Date;
  renewsAt?: Date;
  cancelledAt?: Date;
};

export function canUseProFeatures(
  subscription: Subscription,
  now: Date,
) {
  if (subscription.status === "cancelled")
    return false;

  if (
    subscription.trialEndsAt &&
    subscription.trialEndsAt > now
  )
    return true;

  return (
    subscription.renewsAt !== undefined
  );
}

Good TypeScript Example

type Subscription =
  | { kind: "trial"; userId: string; trialEndsAt: Date }
  | { kind: "active"; userId: string; renewsAt: Date }
  | { kind: "cancelled"; userId: string; cancelledAt: Date };

export function canUseProFeatures(subscription: Subscription, now: Date) {
  switch (subscription.kind) {
    case "trial":
      return subscription.trialEndsAt > now;
    case "active":
      return subscription.renewsAt > now;
    case "cancelled":
      return false;
  }
}
type Subscription =
  | {
      kind: "trial";
      userId: string;
      trialEndsAt: Date;
    }
  | {
      kind: "active";
      userId: string;
      renewsAt: Date;
    }
  | {
      kind: "cancelled";
      userId: string;
      cancelledAt: Date;
    };

export function canUseProFeatures(
  subscription: Subscription,
  now: Date,
) {
  switch (subscription.kind) {
    case "trial":
      return subscription.trialEndsAt > now;
    case "active":
      return subscription.renewsAt > now;
    case "cancelled":
      return false;
  }
}

What Changed

  • The bad version lets cancelled subscriptions keep trial or renewal dates, so callers have to guess which field wins.
  • The good version gives each state only the fields it can legally have.
  • The access rule becomes a direct product decision instead of a pile of defensive checks.

Realistic Example

Illegal states unrepresentable: Realistic Example

This example uses a checkout flow where payment method rules differ by plan.

Bad TypeScript Example

type Checkout = {
  userId: string;
  plan: "free" | "pro" | "team";
  paymentMethodId?: string;
  seatCount?: number;
};

export function createCheckoutSession(input: Checkout) {
  if (input.plan !== "free" && !input.paymentMethodId) {
    throw new Error("Payment method required");
  }

  return { plan: input.plan, seats: input.seatCount ?? 1 };
}
type Checkout = {
  userId: string;
  plan: "free" | "pro" | "team";
  paymentMethodId?: string;
  seatCount?: number;
};

export function createCheckoutSession(
  input: Checkout,
) {
  if (
    input.plan !== "free" &&
    !input.paymentMethodId
  ) {
    throw new Error(
      "Payment method required",
    );
  }

  return {
    plan: input.plan,
    seats: input.seatCount ?? 1,
  };
}

Good TypeScript Example

type CheckoutRequest =
  | { kind: "free"; userId: string }
  | { kind: "paid"; userId: string; plan: "pro"; paymentMethodId: string }
  | { kind: "team"; userId: string; paymentMethodId: string; seatCount: number };

export function createCheckoutSession(input: CheckoutRequest) {
  switch (input.kind) {
    case "free":
      return { plan: "free", seats: 1 };
    case "paid":
      return { plan: input.plan, paymentMethodId: input.paymentMethodId, seats: 1 };
    case "team":
      return { plan: "team", paymentMethodId: input.paymentMethodId, seats: input.seatCount };
  }
}
type CheckoutRequest =
  | {
      kind: "free";
      userId: string;
    }
  | {
      kind: "paid";
      userId: string;
      plan: "pro";
      paymentMethodId: string;
    }
  | {
      kind: "team";
      userId: string;
      paymentMethodId: string;
      seatCount: number;
    };

export function createCheckoutSession(
  input: CheckoutRequest,
) {
  switch (input.kind) {
    case "free":
      return {
        plan: "free",
        seats: 1,
      };
    case "paid":
      return {
        plan: input.plan,
        paymentMethodId:
          input.paymentMethodId,
        seats: 1,
      };
    case "team":
      return {
        plan: "team",
        paymentMethodId:
          input.paymentMethodId,
        seats: input.seatCount,
      };
  }
}

What Changed

  • The bad version allows free plans with payment methods and paid plans without payment methods.
  • The good version makes the plan choice decide which fields are required.
  • The function no longer has to repair or reject combinations that the type could have prevented.

System Example

Illegal states unrepresentable: System Example

At system scale, illegal states often appear when database rows, webhook events, and access checks use the same loose shape.

Larger System-Level Bad TypeScript Example

type SubscriptionRow = {
  userId: string;
  status: string;
  stripeSubscriptionId?: string;
  renewsAt?: string;
  cancelledAt?: string;
  accessEndsAt?: string;
};

export function projectAccess(row: SubscriptionRow) {
  const active = row.status !== "cancelled" || row.accessEndsAt !== undefined;
  return {
    userId: row.userId,
    hasProAccess: active,
    nextBillingDate: row.renewsAt,
  };
}
type SubscriptionRow = {
  userId: string;
  status: string;
  stripeSubscriptionId?: string;
  renewsAt?: string;
  cancelledAt?: string;
  accessEndsAt?: string;
};

export function projectAccess(
  row: SubscriptionRow,
) {
  const active =
    row.status !== "cancelled" ||
    row.accessEndsAt !== undefined;

  return {
    userId: row.userId,
    hasProAccess: active,
    nextBillingDate: row.renewsAt,
  };
}

Larger System-Level Good TypeScript Example

type SubscriptionProjection =
  | {
      kind: "active";
      userId: string;
      stripeSubscriptionId: string;
      renewsAt: Date;
    }
  | {
      kind: "cancelled-with-access";
      userId: string;
      accessEndsAt: Date;
    }
  | {
      kind: "cancelled";
      userId: string;
      cancelledAt: Date;
    };

export function projectAccess(subscription: SubscriptionProjection, now: Date) {
  switch (subscription.kind) {
    case "active":
      return { userId: subscription.userId, hasProAccess: true, nextBillingDate: subscription.renewsAt };
    case "cancelled-with-access":
      return { userId: subscription.userId, hasProAccess: subscription.accessEndsAt > now };
    case "cancelled":
      return { userId: subscription.userId, hasProAccess: false };
  }
}
type SubscriptionProjection =
  | {
      kind: "active";
      userId: string;
      stripeSubscriptionId: string;
      renewsAt: Date;
    }
  | {
      kind: "cancelled-with-access";
      userId: string;
      accessEndsAt: Date;
    }
  | {
      kind: "cancelled";
      userId: string;
      cancelledAt: Date;
    };

export function projectAccess(
  subscription: SubscriptionProjection,
  now: Date,
) {
  switch (subscription.kind) {
    case "active":
      return {
        userId: subscription.userId,
        hasProAccess: true,
        nextBillingDate:
          subscription.renewsAt,
      };
    case "cancelled-with-access":
      return {
        userId: subscription.userId,
        hasProAccess:
          subscription.accessEndsAt > now,
      };
    case "cancelled":
      return {
        userId: subscription.userId,
        hasProAccess: false,
      };
  }
}

What Changed

  • The bad version treats string status plus optional dates as enough information, which can grant access after cancellation by accident.
  • The good version separates active billing, cancelled-but-still-entitled access, and fully cancelled access.
  • Billing dates only exist on active subscriptions, so UI and support tools cannot display a fake renewal for a cancelled user.

When To Use It

Illegal states unrepresentable: When To Use It

Use This When

  • A value can be in a small number of meaningful product states.
  • Some fields only make sense in one state, such as cancelledAt only existing on cancelled subscriptions.
  • The wrong combination would affect billing, access, permissions, workflow status, or customer support.

Avoid This When

  • The value is just display data with no important rules.
  • A simple required field or enum is enough to prevent the mistake.
  • The variants would be so tiny and numerous that they make normal changes harder to read.

Tradeoffs

This approach adds type definitions up front. The payoff is fewer runtime checks and clearer transitions later. If every caller immediately casts around the type, the model is probably too strict or sitting at the wrong boundary.

  • Branded types
  • Parsed inputs
  • Typed errors

Practice Prompt

Illegal states unrepresentable: Practice Prompt

Beginner Exercise

Find a type with a status field and two or more optional fields. Write down one field combination that should never happen.

Intermediate Exercise

Refactor that type into a discriminated union with one variant per valid state. Update one function so it switches on the variant instead of checking optional fields.

Stretch Exercise

Add one transition function, such as cancelSubscription or markPaymentPastDue, that only accepts the state it can legally change.

Reflection Question

Which invalid product state did the new type make impossible, and would that mistake have mattered to a user or support person?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.