Making Invalid States Unrepresentable with Gleam's Custom Types
Gleam's custom types make invalid states unrepresentable by forcing exhaustive pattern matching, turning runtime bugs into compile-time errors.
06 Sept 2025, 07:27 UTC

The Problem: Runtime Errors from Impossible States
Imagine a user authentication system where a user can be in one of three states: Active, Suspended, or Deleted. In many languages, you might represent this with a string or enum, but what happens when your code accidentally creates a state like PendingVerification that your business logic never handles? At runtime, you get unexpected behavior or crashes.
Gleam solves this by making invalid states unrepresentable through its custom types. The compiler won't let you construct values that don't match your defined variants, and it forces you to handle every possible case.
Custom Types Encode Domain Invariants
In Gleam, you define custom types using the customtype keyword. Each variant can carry data, allowing you to encode rich domain constraints directly in the type system.
Consider a bank account balance that should never be negative:
pub customtype AccountStatus {
Active(balance: Int)
Frozen(reason: String)
Closed
}
Here, Active carries a balance, Frozen carries a reason, and Closed carries no data. The compiler knows these are the only three possibilities, and each one has a specific shape of data.
Exhaustive Matching Prevents Silent Bugs
When you pattern match on a custom type, Gleam's compiler checks that you handle all variants. This means if you later add a Dormant variant to AccountStatus, every function that matches on it will fail to compile until you explicitly handle the new case.
This is a fundamental shift: instead of discovering missing cases at runtime through testing or production errors, the compiler finds them for you.
Worked Example: User Status Management
Let's implement a function that deactivates users. We'll start with a simple type and evolve it:
// Step 1: Define the type
pub customtype UserStatus {
Active
Inactive(reason: String)
}
// Step 2: Write a function that matches on it
pub fn deactivate_user(status: UserStatus, reason: String) -> UserStatus {
case status {
Active -> Inactive(reason)
Inactive(_) -> Inactive(reason)
}
}
// Step 3: Try adding a new variant
// pub customtype UserStatus {
// Active
// Inactive(reason: String)
// Suspended(reason: String) // NEW VARIANT
// }
// The compiler now errors on the case expression:
// Error: Missing cases
// UserStatus::Suspended
The compiler immediately tells you what's missing. You must add Suspended(reason) -> Inactive(reason) or whatever business logic applies.
Trade-offs: Verbosity vs. Safety
Explicit error handling and exhaustive matching do increase code verbosity. Every function that returns a Result must consider the error case. Pattern matching on every variant requires more lines than a simple if-else chain.
However, this verbosity is intentional. It forces developers to think about error cases and edge conditions at the type level, catching bugs before they reach production. The trade-off is clear: write more code upfront, or debug cryptic runtime failures later.
Practical Verification
To see this in action, create a Gleam project and add a custom type with multiple variants. Write a function that matches on it, then add a new variant without updating the match. The compiler will produce an error like:
error: Missing cases
| The case expression does not handle all possible values of the
| custom type. It is missing the following constructors:
| MyType::NewVariant
Actionable Takeaway
Start using custom types to model your domain invariants. Begin with simple sum types representing your business states, then let the compiler guide you through refactoring. When you need to add a new state or transition, update the type definition first—your compiler will show you exactly what code needs updating. This approach turns your type system into a safety net that catches logic errors before they become bugs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.