Replacing Stringly Typed Status Fields with Custom Types in Gleam
A practical look at using Gleam custom types and exhaustive pattern matching to replace fragile string status fields, with a worked example and honest trade-offs.
18 Oct 2025, 01:03 UTC

A status field stored as a String feels harmless until the day a typo reaches production. A background job writes "shippd" instead of "shipped", an API client sends "Paid" with a capital P, or a new state is added in one service but not another. The code compiles, the tests pass, and the bug hides in plain sight. In Gleam, you can remove that entire class of problem by replacing the string with a custom type and letting the compiler enforce every possible value.
This is not a performance optimization. It is representational safety: the type system prevents invalid status values before your program runs.
Why string statuses rot
When a status is just a string, every function that reads it must defend against values the type system cannot see. A typical Gleam record might look like this:
pub type Order {
Order(id: String, status: String)
}
pub fn can_ship(order: Order) -> Bool {
order.status == "paid"
}
This compiles whether status is "paid", "Paid", or "paidd". The comparison in can_ship is a runtime guess. If a developer later adds a "partially_paid" state, nothing forces them to revisit can_ship. The failure surfaces when a customer complains, not when the code is built.
Custom types as closed sets
Gleam custom types let you enumerate the exact states your domain allows. Instead of an open set of strings, you define a closed set of variants:
pub type Order {
Order(id: String, status: OrderStatus)
}
pub type OrderStatus {
Draft
Pending
Paid
Shipped
}
Now OrderStatus can only be one of four values. A typo is impossible because the variant names are checked by the compiler. This also aligns with Gleam's explicit approach to absence: rather than null, the standard library provides Option(a) with None and Some(value), so callers must handle the empty case. The same philosophy applies to statuses—make the valid set explicit, and the compiler holds you to it.
Exhaustiveness as a refactoring tool
The real payoff arrives when the domain changes. Suppose you add a Cancelled variant:
pub type OrderStatus {
Draft
Pending
Paid
Shipped
Cancelled
}
Any case expression that matches on OrderStatus without handling Cancelled will fail to compile. The compiler lists every unhandled site, turning a risky manual search into a checklist. For example, this transition helper:
pub fn can_ship(order: Order) -> Bool {
case order.status {
Paid -> True
Shipped -> True
Draft -> False
Pending -> False
}
}
will produce a compile error after the new variant is added, pointing directly at the missing branch. If you handle errors as values with Result(ok, error), you can keep success-path code linear using the use keyword:
pub fn parse_status(raw: String) -> Result(OrderStatus, String) {
case raw {
"draft" -> Ok(Draft)
"pending" -> Ok(Pending)
"paid" -> Ok(Paid)
"shipped" -> Ok(Shipped)
_ -> Error("Unknown status: " <> raw)
}
}
Note that the wildcard _ here is acceptable: it handles the boundary between untyped input and your typed domain. Inside transition logic, however, a wildcard is dangerous because it silently absorbs future variants.
Trade-offs and honest limitations
Custom types do not enforce transition order. Nothing stops you from writing a function that turns a Draft directly into Shipped. If you need a state machine, you must encode it in the type structure or guard it with tests. The type prevents invalid values, not invalid sequences.
Exhaustiveness checking also depends on discipline. A broad _ branch in business logic will swallow new variants without a compiler error. Likewise, let assert and panic are escape hatches; using them for ordinary input errors converts compile-time safety into runtime crashes.
Finally, Gleam can target both BEAM and JavaScript, but available standard-library modules and runtime behavior can differ by target. If you support multiple runtimes, test each one you claim to support.
Verifying the pattern in your own project
These examples assume a recent Gleam toolchain. Exact syntax and standard-library names can move between releases, so confirm your environment first.
- Run
gleam --versionin your terminal to confirm the toolchain is installed. These commands require only standard user permissions in your working directory. - Run
gleam new status_demoto create a fresh project. Replacestatus_demowith your preferred project name, then replace the generated module with theOrderandOrderStatusdefinitions above. - Run
gleam buildin the project root. Expect a clean compile. - Add
CancelledtoOrderStatusand rungleam buildagain. Expect a non-exhaustivecaseerror pointing atcan_ship. - Run
gleam testto confirm your test suite passes before and after the change.
If the compiler does not report the missing branch, check whether a wildcard _ is absorbing it. That is the signal to tighten the match rather than loosen the type.
The takeaway
Replacing a string status with a Gleam custom type is a small change with an outsized effect: it moves an entire category of bugs from runtime to compile time. The compiler becomes your refactoring assistant, and the next developer who adds a state gets an immediate checklist instead of a production incident. Start with one status field, verify the exhaustiveness error in a scratch project, and expand from there.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.