Build Your Own Bedrock · Part 4

Result: errors are values

TypeFirst · AgileLabs Engineering · 7 min read

Part 3 gave you a type for absence. Absence can't answer the follow-up question: why? A login that returns Maybe<User> can't distinguish a wrong password from a locked account. Mainstream JS answers with throw — and your caller's type signature says nothing about it. The next file in your bedrock, Core/Data/Result.ts, replaces that whole mechanism with a plain object.

The type, and the _t convention

// Core/Data/Result.ts
export type Result<E, T> = ResultErr<E> | ResultOk<T>
export type ResultErr<E> = {
  readonly _t: "Err"
  readonly error: E
}
export type ResultOk<T> = {
  readonly _t: "Ok"
  readonly value: T
}

/** Creates an Ok Result */
export function ok<T>(value: T): ResultOk<T> {
  return { _t: "Ok", value }
}

/** Creates an Err Result */
export function err<E>(error: E): ResultErr<E> {
  return { _t: "Err", error }
}

Two things to copy into your own bedrock. First, the error type E comes before T and is fully generic — you'll fill it with string-literal unions like "WRONG_PASSWORD" | "ACCOUNT_LOCKED", so a switch over failures is exhaustive and the compiler flags the case you forgot. Second, the discriminant field is named _t — and that exact name is used by every tagged union in the repo, from RemoteData to API response types. One convention means every pattern match in the codebase reads identically, the underscore keeps it clear of domain field names, and tagged-union decoders can key on it mechanically. Pick your discriminant name once; never deviate.

Functions, not methods

// Core/Data/Result.ts
export function mapOk<E, T1, T2>(
  result: Result<E, T1>,
  fn: (t: T1) => T2,
): Result<E, T2> {
  return result._t === "Ok" ? ok(fn(result.value)) : result
}

export function mapErr<E1, E2, T>(
  result: Result<E1, T>,
  fn: (e: E1) => E2,
): Result<E2, T> {
  return result._t === "Err" ? err(fn(result.error)) : result
}

mapOk transforms success and lets failure pass through untouched; mapErr is its mirror, the tool for translating a low-level error into your domain's vocabulary. Notice these are free functions over plain objects, not methods on a class. That's deliberate: a Result stays pure data — it survives JSON.stringify, structured clone, and a trip through an HTTP response without losing behavior it never had. That property is about to pay off.

Burying an error, on purpose

// Core/Data/Result.ts
/** Converts a Result to a toMaybe
 * burying the error;
 */
export function toMaybe<E, T>(result: Result<E, T>): Maybe<T> {
  return result._t === "Ok" ? result.value : null
}

Not every caller cares why something failed. A UI toggling a button only needs "did it work". toMaybe downgrades Result to Maybe, discarding the error — and because it's an explicit call, the burial is visible in the code, not an accident. The module rounds this out with value() and error(), which pluck one side as a Maybe. In Part 6 you'll see the repo lean on toMaybe to derive every Maybe-flavored constructor from its Result-flavored twin.

Results that travel

Because a Result is plain JSON-shaped data, a server can send one to a browser. Bedrock uses exactly this for server-sent events: each SSE message is a serialized Result, and the client re-verifies it at the boundary with a decoder keyed on — what else — _t:

// Core/Data/Result.ts
export function resultDecoder<E, T>(
  errorDecoder: JD.Decoder<E>,
  valueDecoder: JD.Decoder<T>,
): JD.Decoder<Result<E, T>> {
  return JD.taggedUnion("_t", {
    Ok: resultOkDecoder(valueDecoder),
    Err: resultErrDecoder(errorDecoder),
  })
}

The two branch decoders it delegates to, resultOkDecoder and resultErrDecoder, are more roundabout than you'd expect — a known inference quirk in the decoders library (nvie/decoders#930) forces a two-step decode. The file documents the workaround with a link to the issue. Your bedrock will accumulate a few of these; annotate them, because the "why" is the part that rots first.

Why domain code never throws

With Result in place, an exception has no remaining job in domain logic. A thrown error is invisible in a function's signature, skips every type check between the throw and whatever catch happens to be listening, and takes down an SSE stream or a request handler when nobody is. A returned ResultErr is the opposite on every axis: declared, typed, exhaustively matched. That's why bedrock's domain code contains zero throw statements — the single carve-out is throwIfNull from Part 3, which exists to feed decoders. Failure isn't an event that interrupts your program; it's a value your program computes.

In your language

Rust made this mainstream: Result<T, E> with ? for propagation and match for exhaustiveness. Swift has the same shape as an enum, Result<Success, Failure>, with associated values. In Kotlin, model it as a sealed interface with Ok/Err data classes and let when enforce coverage. Go's (value, err) pair is errors-as-values without the union — the compiler won't force you to look at err, which is exactly the gap a real sum type closes. Haskell's Either and Elm's Result are the ancestors; if you have those, you already live here.

Want the war stories? The companion post Never throw makes the case against exceptions at length — including what happened to the codebases that kept them.

← Part 3: Maybe: a null policy in 52 lines · All posts · Part 5: Opaque: values you can't forge →