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
cancelledAtplusrenewsAt. - 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
cancelledAtonly 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.
Related Concepts
- 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.