Using Gleam’s Result Type for Predictable Error Handling in a Small HTTP Service
Learn how to enforce compile‑time error handling in a Gleam HTTP service with the Result type. The article covers design requirements, minimal implementation, trust boundaries, operational checks, and when to rethink the design.
02 Feb 2026, 22:21 UTC

Problem & Takeaway
When building a web service in Gleam, unchecked exceptions can make debugging and testing difficult. The Result type forces every function to return either Ok or Err, giving the compiler a guarantee that error paths are handled. This article shows how to wire that guarantee into an HTTP handler, keep domain logic pure, and design for easy operational checks.
Requirements
- Gleam compiler 0.16 or newer (Result is only available from this version).
- Minimal runtime overhead – the service should still be performant.
- Clear mapping from domain errors to HTTP status codes.
- Side‑effects (e.g., DB writes) must occur only after a successful
Ok. - Support for asynchronous I/O via
Taskwithout losing type safety.
Smallest Viable Design
At its core, the design consists of three layers:
- Domain Layer: Pure functions that perform business logic and return
Result. - Adapter Layer: HTTP handlers that pattern‑match on the
Resultand translate it to a response. - Infrastructure Layer: External calls (DB, other HTTP services) that are wrapped in
Resultbefore being passed back to the domain layer.
Example: a create_user/1 domain function returns Result User String where Err contains a human‑readable message. The HTTP handler calls it, pattern‑matches, and returns 201 on Ok or 400 on Err.
Trust & Data Boundaries
Domain logic is isolated from I/O. All external interactions are wrapped in Result before they reach the domain. This means:
- Domain functions never panic; they only return
OkorErr. - External failures (e.g., network timeouts) are mapped to a specific
Errvariant, keeping the contract explicit. - The HTTP layer is the only place that knows about HTTP status codes, so changes to the API surface do not ripple into business logic.
Operational Checks
To confirm that the design works as intended, perform the following checks:
- Compile‑time coverage: Ensure every
Resultis pattern‑matched. The compiler will emit an error if any branch is missing. - Unit tests: Write tests that call domain functions directly, asserting both
OkandErrpaths. Then test the HTTP handler with a mock request and verify the status code. - Contract tests: Use
@specannotations to document the exact error variants. Automated tests can then check that the implementation still returns those variants. - Runtime monitoring: Log the frequency of each
Errvariant to spot regressions or new error patterns.
Failure Modes & Mitigations
- Uncovered Result variant: If a new error type is added to a domain function but the HTTP handler doesn’t pattern‑match it, the compiler will flag the missing branch. This forces the handler to be updated.
- External error mapping: Forgetting to convert an external error into a domain
Resultwill break type safety. Always wrap external calls with a helper that returnsResult. - Database write failure after Ok: If the domain returns
Okbut the subsequent DB write fails, the service must roll back or signal the failure. Keep the DB write inside the same asyncTaskchain, returning aResultthat the handler can interpret.
When to Change the Design
Consider revisiting the architecture when:
- New error categories require different HTTP status codes (e.g., 429 Too Many Requests).
- The service moves from REST to WebSocket or gRPC, where the error handling contract changes.
- Performance profiling shows that the current
Resultpattern‑matching adds measurable overhead. - The number of error variants grows beyond a manageable set, making the handler unwieldy.
Concrete Example
Domain Function
pub fn create_user(name : String) : Result User String {
if name == "" {
Err("Name cannot be empty")
} else {
// Pretend we insert into DB and get an ID
let id = 42
Ok(%User{id, name})
}
}
HTTP Handler
import gleam/http as http
import gleam/Result as Result
pub fn handle_create(request : http.Request) : http.Response {
let body = http.request.body_text(request)
case Result.map_err(Result.from_string(body), fn(name) {
create_user(name)
}) {
Ok(user) -> http.Response.ok(json_encode(user))
Err(msg) -> http.Response.bad_request(msg)
}
}
Explanation:
- The handler extracts the request body, converts it to a
Result(to handle empty bodies), then callscreate_user. - Pattern matching on the
Resultguarantees a 200 on success and a 400 on any error. - No global exception handling is needed.
Build & Test
# Compile the project
$ gleam build
# Run unit tests
$ gleam test
Expected checks:
- The compiler must not emit any
Resultcoverage errors. - Unit tests should confirm that
handle_createreturns 201 for a valid name and 400 for an empty name.
Limitations & Practical Verification
- Result is only available in Gleam 0.16+. If you are on an older compiler, upgrade before using this pattern.
- Pattern matching on
Resultmust cover all variants; otherwise, the compiler will flag the code as unreachable. - When integrating with third‑party HTTP clients, always map their errors into your own
Resultvariants to preserve the contract. - Testing the HTTP layer requires a lightweight test server or a mock framework that can simulate requests and assert responses.
To verify the design in production, add a simple endpoint that returns the current count of each error variant. Monitor the metrics; a sudden spike indicates a new failure mode that may warrant a design change.
Conclusion
Using Gleam’s Result type gives you compile‑time guarantees and clean separation of concerns. By keeping domain logic pure and handling errors explicitly in the HTTP layer, you create a service that is easier to test, maintain, and extend. When the error landscape changes or new protocols are introduced, the same pattern can be adapted with minimal friction.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.