"Impossible!" - Vizzini
"You Keep Using That Word, I Do Not Think It Means What You Think It Means" - Inigo Montoya
Dog year ages ago Richard Feldman held a great talk about making impossible states impossible that has influenced my thinking on this topic since, including how I at the time modeled the XML we were using in the app I was working on.
Here I aim to dig into and list some of the symptoms I've come across and how to remove them.
The examples are in TypeScript and Gleam but the concepts should be broadly applicable to any language/specification that has a decently strong type system.
If you dig Elm or Gleam like me but these concepts are new to you, I can recommend checking out either Gleam's opaque types and phantom types, or Elm's opaque types and phantom types.
If you're more TypeScript inclined then it's worth reading up on how branded types work in TypeScript.
If you find any errors or have any examples that I haven't covered, please let me know.
Table of contents
- Gentle Introduction
- Patterns of Impossible States
Gentle Introduction
Now, a gentle introduction to what is meant by impossible states for those of you who didn't take the time to watch the talk.
// ❌
type Payment = { method: "card" | "cash"; cardNumber?: string };
// allows an impossible state
const p: Payment = { method: "cash", cardNumber: "1234" };
What happens if a downstream system forgets to check method and only looks for cardNumber? And can we avoid this all together?
// ✅ Better type safety.
type Payment = { type: "cash" } | { type: "card"; cardNumber: string };
const p: Payment = { type: "cash", cardNumber: "1234" };
// ^^^^^^^^^^
// Object literal may only specify known properties,
// and 'cardNumber' does not exist in type '{ type: "cash"; }'.ts(2353)
The Gleam version actually turns out shorter and simpler in the more refactored version unlike TypeScript's.
// ❌
pub type Payment {
Payment(
method: PaymentMethod,
card_number: Option(String),
)
}
pub type PaymentMethod {
Card
Cash
}
// allows an impossible state
pub fn bad_cash_payment() -> Payment {
Payment(Cash, Some("1234"))
}
// ✅ Better type safety.
pub type BetterPayment {
Cash
Card(card_number: String)
}
// Cash can never have a card number.
pub fn cash_payment() -> BetterPayment {
Cash
}
Patterns of Impossible States
Even though the idea may now be clear, to actually get into the mindset when producing code, and also spotting them, it helps having patterns. I've attempted to categorize them as best as possible.
Impossible Data Structures
Impossible data structures can usually be re-modeled to eliminate the impossible states. Often these impossible states can be spotted by the existence/over-existence of optional fields.
Different Entity States/Sub-types with Different Field Requirements
Common with fields like 'state' or 'status' or when sub-typing class hierarchies (vehicles, animals etc.).
// ❌ too many optional fields ⇒ impossible states allowed
interface Article {
state: "draft" | "published" | "archived";
title: string;
body: string;
publishedAt?: string;
comments: Comment[];
archivedReason?: string;
archivedAt?: string;
}
// ✅ Impossible states eliminated
type Article =
| {
state: "draft";
title: string;
body: string;
}
| {
state: "published";
title: string;
body: string;
publishedAt: string;
comments: Comment[];
}
| {
state: "archived";
archivedReason: string;
archivedAt: string;
};
// ❌ too many optional fields ⇒ impossible states allowed
pub type BadArticle {
BadArticle(
state: ArticleState,
title: String,
body: String,
published_at: Option(String),
comments: List(Comment),
archived_reason: Option(String),
archived_at: Option(String),
)
}
pub type ArticleState {
DraftState
PublishedState
ArchivedState
}
pub type Comment {
Comment(body: String)
}
// ✅ Impossible states eliminated
pub type Article {
Draft(title: String, body: String)
Published(title: String, body: String, published_at: String, comments: List(Comment))
Archived(archived_reason: String, archived_at: String)
}
Mutually Exclusive Fields
// ❌ mutually exclusive optional fields that can co-exist
interface Auth {
password?: string;
oauthToken?: string;
}
// ✅ Impossible states eliminated
type Auth = { method: "password"; password: string } | { method: "oauth"; oauthToken: string };
// ❌ mutually exclusive optional fields that can co-exist
pub type BadAuth {
BadAuth(password: Option(String), oauth_token: Option(String))
}
// ✅ Impossible states eliminated
pub type Auth {
Password(password: String)
Oauth(oauth_token: String)
}
Mutually Inclusive Fields
// ❌ Config is required if enabled is true
interface X {
enabled?: boolean;
config?: { foo: number };
}
// ✅ Impossible states eliminated
type X = { enabled: false } | { enabled: true; config: { foo: number } };
// ❌ Config is required if enabled is true
pub type BadConfig {
BadConfig(enabled: Option(Bool), config: Option(Config))
}
pub type Config {
Config(foo: Int)
}
// ✅ Impossible states eliminated
pub type X {
Disabled
Enabled(config: Config)
}
Triple Value Boolean
// ❌ What does 'hasAcceptedTerms: null' mean?
interface User {
hasAcceptedTerms?: boolean;
}
// ✅ Clear communication of meaning
interface User {
termsStatus: "accepted" | "rejected" | "pending";
}
// ❌ What does `has_accepted_terms: None` mean?
pub type BadUser {
BadUser(has_accepted_terms: Option(Bool))
}
// ✅ Clear communication of meaning
pub type TermsStatus {
Accepted
Rejected
Pending
}
pub type User {
User(terms_status: TermsStatus)
}
Illegal (Boolean) Combinations
The boolean combinations are probably the easiest to spot but other illegal combinations can occur too, such as enums (a model of exchange rates like EUR/USD should disallow EUR/EUR).
// ❌ Can a door be open and locked at the same time?
interface Door {
isOpen: boolean;
isLocked: boolean;
}
// ✅ No it cannot
type Door = { state: "open" } | { state: "closed" } | { state: "locked" };
// ❌ Can a door be open and locked at the same time?
pub type BadDoor {
BadDoor(is_open: Bool, is_locked: Bool)
}
// ✅ No it cannot
pub type Door {
Open
Closed
Locked
}
The Failure State
In some cases it's handy to actually model the failure state instead of bubbling up an Exception or similar, especially when working in the frontend and you want to show something meaningful to the user.
Case in point from a TypeScript project I'm working on. An entity can have a template associated with it and we want to show a button if it does, but the template comes from a different endpoint. In our frontend the evolution was:
// ❌ doesn't really cover all the cases
const hasTemplate: boolean | undefined
// ⚠️ Somewhat better
const hasTemplate: 'loading' | 'not-found' | 'found'
// ✅ Covers all the states
const templateState: 'loading' | 'not-found' | 'found' | 'failed'
Now we can show something useful regardless of the outcome and we don't have to guess what undefined means.
This scenario is so common that there are ready-made libraries for this such as Elm's RemoteData:
type RemoteData e a
= NotAsked
| Loading
| Failure e
| Success a
Constrained Values
Constrained values, values that typically have constraints that are outside the reach of the type system, tend to require specialized functions/constructors to solve.
Collections with Mandatory Values
Collections that have mandatory values can usually be re-modeled as records with mandatory fields.
// ❌ Still allows an empty array.
type NonEmptyArray<T> = T[];
// ✅ Impossible states eliminated
type NonEmptyArray<T> = { first: T; rest: T[] };
// Implement the functions you need.
/**
* Returns the last element if length > 1,
* else undefined.
*/
function new_non_empty_array<T>(first: T, rest: T[]): NonEmptyArray<T> {
return { first, rest };
}
function to_array<T>(nea: NonEmptyArray<T>): T[] {
return [nea.first, ...nea.rest];
}
// ❌ Still allows an empty list.
pub type BadNonEmptyList(a) {
BadNonEmptyList(List(a))
}
// ✅ Impossible states eliminated
pub opaque type NonEmptyList(a) {
NonEmptyList(first: a, rest: List(a))
}
pub fn new(first: a, rest: List(a)) -> NonEmptyList(a) {
NonEmptyList(first, rest)
}
pub fn to_list(nea: NonEmptyList(a)) -> List(a) {
[nea.first, ..nea.rest]
}
Context Dependency
When a certain context is required, such as in a child-parent relationship.
// ❌ Child can exist without parent
interface Child {
parentId?: string;
value: number;
}
// ✅ Require constructor that supplies the parent
interface Parent {
id: string;
}
interface Child {
parent: Parent;
value: number;
}
function makeChild(parent: Parent, value: number): Child {
return { parent, value };
}
// ❌ Child can exist without parent
pub type BadChild {
BadChild(parent_id: Option(String), value: Int)
}
// ✅ Require constructor that supplies the parent
pub type Parent {
Parent(id: String)
}
pub type Child {
Child(parent: Parent, value: Int)
}
pub fn make_child(parent: Parent, value: Int) -> Child {
Child(parent, value)
}
Constrained Value or Mutually Constrained Values
These are values that are by themselves or collectively required to satisfy some business rule.
// ❌ Not guaranteed to be positive
interface X {
quantity: number;
}
// ✅ Uses brand types to ensure
type Positive = number & { __brand: "Positive" };
function makePositive(n: number): Positive | Error {
return n > 0 ? (n as Positive) : new Error("Not positive");
}
interface X {
quantity: Positive;
}
// ❌ Does not guarantee start <= end in and of itself
interface Interval {
start: Date;
end: Date;
}
// ✅ combine with a branded type
function makeInterval(start: Date, end: Date): Interval | Error {
if (end < start) return new Error("Invalid interval");
return { start, end };
}
// ❌ Not guaranteed to be positive
pub type BadX {
BadX(quantity: Int)
}
// ✅ Uses an opaque type to ensure positivity
pub opaque type Positive {
Positive(Int)
}
pub fn make_positive(n: Int) -> Result(Positive, String) {
if n > 0 {
Ok(Positive(n))
} else {
Error("Not positive")
}
}
pub type X {
X(quantity: Positive)
}
// ❌ Does not guarantee start <= end in and of itself
pub type Interval {
Interval(start: Int, end: Int)
}
// ✅ smart constructor enforces the invariant
pub fn make_interval(start: Int, end: Int) -> Result(Interval, String) {
if end < start {
Error("Invalid interval")
} else {
Ok(Interval(start, end))
}
}
Private Data
Data that should not be exposed/available. This can be solved using branded types or private fields in TypeScript, and opaque types in Gleam/Elm.
// using branded types
type UserId = string & { readonly brand: unique symbol };
function makeUserId(s: string): UserId {
return s as UserId;
}
// using TypeScript's private keyword
class BankAccount {
private balance = 100;
}
new BankAccount().balance; // ❌ Compile-time error
// ⚠️ but still available at runtime so JSON.stringify(bankAccount) will leak it
// using ECMAscript private
class Person {
#ssn: string;
constructor(ssn: string) {
this.#ssn = ssn;
}
getLast4() {
return this.#ssn.slice(-4);
}
}
const p = new Person("123-45-6789");
p.#ssn; // ❌ Error
// using an opaque type
pub opaque type UserId {
UserId(String)
}
pub fn user_id_from_string(s: String) -> UserId {
UserId(s)
}
// callers can never see the inner String
pub fn user_id_to_string(id: UserId) -> String {
let UserId(inner) = id
inner
}
Invalid State Transitions
Invalid state transitions can be protected using types.
interface Doc {
state: "draft" | "submitted" | "approved";
// ...
}
function approve(doc: Doc) {
// ⚠️ requires validation and test cases
if (doc.state !== "submitted") throw new Error("Invalid state");
}
// ✅ Eliminates validation and test cases
// ---- States ----
export interface Draft {
state: "draft";
content: string;
}
export interface Submitted {
state: "submitted";
content: string;
submittedAt: Date;
}
export interface Approved {
state: "approved";
content: string;
approvedAt: Date;
}
// ---- Valid transitions ----
// Only a Draft can be submitted
export function submit(draft: Draft): Submitted;
// Only a Submitted can be approved
export function approve(submitted: Submitted): Approved;
// ❌ single type with a flag field
pub type Doc {
Doc(state: DocState, content: String)
}
pub type DocState {
DraftState
SubmittedState
ApprovedState
}
pub fn bad_approve(doc: Doc) -> Result(Doc, String) {
case doc.state {
SubmittedState -> Ok(Doc(ApprovedState, doc.content))
_ -> Error("Invalid state")
}
}
// ✅ Separate types for each state.
// A phantom type `Article(state)` could also be used.
pub type Draft {
Draft(content: String)
}
pub type Submitted {
Submitted(content: String, submitted_at: String)
}
pub type Approved {
Approved(content: String, approved_at: String)
}
pub fn submit(draft: Draft, now: String) -> Submitted {
let Draft(content) = draft
Submitted(content, now)
}
pub fn approve(submitted: Submitted, now: String) -> Approved {
let Submitted(content, _) = submitted
Approved(content, now)
}
Top comments (0)