The result is local. The commitment is not.
A checkout parses a coupon, looks it up, prices a cart, and charges a payment method. Any
step may return an expected failure. The first Result<T, E> makes that failure
visible; the second and third reveal whether the representation helps or starts charging interest.
With a hand-rolled union, each caller checks ok and returns the error. That is correct
and pleasantly small at one boundary. At depth, the function becomes a ladder of plumbing. Combinators
move that plumbing into named operations, but someone must own their semantics—especially for
async work and mixed error types.
The choice is how much of your codebase should share the answer to “what happens when this fails?”
Read the smallest resultTypeScript · no library required
export function parseCoupon(input: string): Result<string, Failure> {
const code = input.trim();
return couponFormat.test(code)
? ok(code)
: err({ kind: 'invalid', field: 'coupon', message: 'Enter a valid coupon.' });
}
export function lookupCoupon(code: string): Result<number, Failure> {
return code === 'SAVE10'
? ok(10)
: err({ kind: 'not-found', message: 'That coupon does not exist.' });
}
export function discountedTotal(percent: number): number {
return (subtotal * (100 - percent)) / 100;
}
/** R1: every dependent step gets its own guard clause. */
export function checkout(input: string): Result<number, Failure> {
const coupon = parseCoupon(input);
if (!coupon.ok) return coupon;
const percent = lookupCoupon(coupon.value);
if (!percent.ok) return percent;
return ok(discountedTotal(percent.value));
} The starting type is only two cases. It is not a promise that every failure in the system belongs there; it is a contract for this operation’s expected outcomes.
Each step buys help by claiming more of the codebase.
Hand-rolled, no combinators
A discriminated union and guard clauses. Zero dependencies, zero propagation magic.
Best when one boundary is enough.Hand-rolled, with your helpers
Own map, andThen, mapError, and unwrapOr.
A Result library
Someone else maintains combinators and edge cases; your signatures adopt its type.
Useful when the codebase already speaks Result.An effect system
Errors, requirements, success, retries, and timeouts compose in one larger model.
Worth it when operational composition is the problem.| Position | Dependencies | Async | Spread | Cost to leave |
|---|---|---|---|---|
| R1 · Union | None | Await, then guard | One function | Low |
| R2 · Own helpers | None | Every helper needs a twin | Where helpers are used | Medium |
| R3 · Library | One package | Usually provided | Every signature above the call | High |
| R4 · Effect | Runtime + type | Native operators | The whole effectful boundary | Very high |
Read the owned-combinator positionTypeScript · the surface you now maintain
export function map<T, U, E>(result: Result<T, E>, transform: (value: T) => U): Result<U, E> {
return result.ok ? ok(transform(result.value)) : { ok: false, error: result.error };
}
export function andThen<T, U, E>(
result: Result<T, E>,
next: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? next(result.value) : { ok: false, error: result.error };
}
export function mapError<T, E, F>(result: Result<T, E>, transform: (error: E) => F): Result<T, F> {
return result.ok ? result : { ok: false, error: transform(result.error) };
}
export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
return result.ok ? result.value : fallback;
}
/** R2: the same checkout, with propagation moved into helpers you now maintain. */
export function checkoutWithCombinators(input: string): Result<number, Failure> {
return map(andThen(parseCoupon(input), lookupCoupon), discountedTotal);
} The helpers are not hard because map is clever. They are expensive because
the codebase will ask for async versions, error-type widening, logging, retries, and a
rule for what unwrapOr is allowed to hide.
Watch the same failure travel through four adoption choices.
Choose a position, a chain length, and the first failing step. The outcome stays the same;
the maintenance surface changes. Notice the asymmetry: in Go, the (value, error) pair is already the standard shape, so a Result type is the extra commitment; in TypeScript, a
library is an additional codebase-wide commitment.
The first chain reads well; async twins, error widening, and edge cases become yours.
3 operations share one propagation vocabulary.
No dependency; async twins and error widening become your maintenance job.
The controls change a local model; nothing is saved.Read the call siteTypeScript · guards beside composition
export function observe(
position: Position,
chainLength: ChainLength,
failureAt: FailureAt
): Observation {
const result =
position === 'hand-rolled'
? runWithGuards(chainLength, failureAt)
: runWithOwnCombinators(chainLength, failureAt);
const failureIndex =
failureAt === 'none' ? -1 : steps.indexOf(failureAt as (typeof steps)[number]);
const reachedFailure = failureIndex >= 0 && failureIndex < chainLength;
const completedSteps = reachedFailure ? failureIndex : chainLength;
const propagation =
position === 'hand-rolled'
? `${chainLength} operation${chainLength === 1 ? '' : 's'} need${chainLength === 1 ? 's' : ''} an explicit guard.`
: `${chainLength} operation${chainLength === 1 ? '' : 's'} share one propagation vocabulary.`;
const descriptions: Record<Position, { spread: string; tradeoff: string }> = {
'hand-rolled': {
spread: 'One function',
tradeoff: 'No dependency; every deeper call site repeats the check.'
},
'own-combinators': {
spread: 'Where helpers are used',
tradeoff: 'No dependency; async twins and error widening become your maintenance job.'
},
library: {
spread: 'Every signature above the call',
tradeoff:
'Combinators and edge cases are maintained elsewhere, but the type becomes a codebase commitment.'
},
'effect-system': {
spread: 'The whole effectful boundary',
tradeoff:
'Requirements, retries, and timeouts compose; adopting or leaving is a large rewrite.'
}
};
return {
position,
chainLength,
failureAt,
result: result.ok ? 'success' : 'failure',
completedSteps,
propagation,
spread: descriptions[position].spread,
tradeoff: descriptions[position].tradeoff
};
}
export const checkoutInputs = ['SAVE10', 'SAVE5', 'save 10!'] as const;
/** One line per input, printed identically by the Go program. */
export function describeCheckout(input: string, result: Result<number, Failure>): string {
return result.ok
? `${JSON.stringify(input)}: total ${result.value}`
: `${JSON.stringify(input)}: ${result.error.kind} (${result.error.message})`;
}
export function runExample(): string[] {
return checkoutInputs.map((input) => describeCheckout(input, checkoutWithCombinators(input)));
} The lab’s library and effect positions are decision models, not claims that this lesson has installed a particular dependency. The question is what the adopted abstraction owes you once its type appears above the original function.
Choose the smallest position that pays for itself.
The best answer depends on where the Result type already lives, how deep the composition is, and whether requirements or operational controls are part of the problem.
The same idea is a different commitment in each language.
TypeScript starts with a union and must decide how much library or runtime it wants. Go
already has a standard shape: every call returns (value, error) and the caller
forwards with if err != nil. A generic Result[T] is possible, but
Go methods cannot declare their own type parameters, so Map and AndThen become plain functions, and every standard-library call still returns a pair
you have to convert. Both Go versions print the same totals as the TypeScript ones.
TypeScript · own helpers
export function map<T, U, E>(result: Result<T, E>, transform: (value: T) => U): Result<U, E> {
return result.ok ? ok(transform(result.value)) : { ok: false, error: result.error };
}
export function andThen<T, U, E>(
result: Result<T, E>,
next: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? next(result.value) : { ok: false, error: result.error };
}
export function mapError<T, E, F>(result: Result<T, E>, transform: (error: E) => F): Result<T, F> {
return result.ok ? result : { ok: false, error: transform(result.error) };
}
export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
return result.ok ? result.value : fallback;
}
/** R2: the same checkout, with propagation moved into helpers you now maintain. */
export function checkoutWithCombinators(input: string): Result<number, Failure> {
return map(andThen(parseCoupon(input), lookupCoupon), discountedTotal);
} Go · the standard pair
func parseCoupon(input string) (string, error) {
code := strings.TrimSpace(input)
if !couponFormat.MatchString(code) {
return "", &Failure{Kind: "invalid", Message: "Enter a valid coupon."}
}
return code, nil
}
func lookupCoupon(code string) (int, error) {
if code == "SAVE10" {
return 10, nil
}
return 0, &Failure{Kind: "not-found", Message: "That coupon does not exist."}
}
func discountedTotal(percent int) int {
return subtotal * (100 - percent) / 100
}
// checkout is Go's standard position: the (value, error) pair is the result type,
// and each dependent step forwards its error explicitly.
func checkout(input string) (int, error) {
code, err := parseCoupon(input)
if err != nil {
return 0, err
}
percent, err := lookupCoupon(code)
if err != nil {
return 0, err
}
return discountedTotal(percent), nil
} Go · a Result[T] you own
// Result is the helper you would own. Go methods cannot declare their own type parameters,
// so Map and AndThen are plain functions, and every standard-library call still returns a
// (value, error) pair that has to be converted with From.
type Result[T any] struct {
Value T
Err error
}
func From[T any](value T, err error) Result[T] {
return Result[T]{Value: value, Err: err}
}
func AndThen[T, U any](r Result[T], next func(T) (U, error)) Result[U] {
if r.Err != nil {
return Result[U]{Err: r.Err}
}
return From(next(r.Value))
}
func Map[T, U any](r Result[T], transform func(T) U) Result[U] {
if r.Err != nil {
return Result[U]{Err: r.Err}
}
return Result[U]{Value: transform(r.Value)}
}
func checkoutWithCombinators(input string) Result[int] {
return Map(AndThen(From(parseCoupon(input)), lookupCoupon), discountedTotal)
} Read the library boundary in TypeScriptWhat the real dependency would own
/** R3: the library's type is now the signature every caller above this one sees. */
export function checkoutThroughLibrary(input: string): LibraryResult<number, Failure> {
return LibraryResult.from(parseCoupon(input)).andThen(lookupCoupon).map(discountedTotal);
} The example defines a small stand-in for the package so the lesson runs without a download. In a real R3 choice, the library’s type and its async variant would appear in the signatures above this call.
See the complete programsCopyable source plus invocation
export type Failure =
| { kind: 'invalid'; field: 'coupon'; message: string }
| { kind: 'not-found'; message: string }
| { kind: 'unavailable'; retryAfterSeconds: number; message: string };
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type Position = 'hand-rolled' | 'own-combinators' | 'library' | 'effect-system';
export type ChainLength = 1 | 2 | 3 | 4;
export type FailureAt = 'none' | 'parse' | 'lookup' | 'price' | 'charge';
export type Observation = Readonly<{
position: Position;
chainLength: ChainLength;
failureAt: FailureAt;
result: 'success' | 'failure';
completedSteps: number;
propagation: string;
spread: string;
tradeoff: string;
}>;
const steps = ['parse', 'lookup', 'price', 'charge'] as const;
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
function failureFor(step: FailureAt): Failure {
switch (step) {
case 'parse':
return { kind: 'invalid', field: 'coupon', message: 'Enter a valid coupon.' };
case 'lookup':
return { kind: 'not-found', message: 'That coupon does not exist.' };
case 'price':
return {
kind: 'unavailable',
retryAfterSeconds: 5,
message: 'Pricing is temporarily unavailable.'
};
case 'charge':
return {
kind: 'unavailable',
retryAfterSeconds: 10,
message: 'Payment is temporarily unavailable.'
};
case 'none':
return { kind: 'invalid', field: 'coupon', message: 'No failure was selected.' };
}
}
const subtotal = 100;
const couponFormat = /^[A-Z]+[0-9]{1,2}$/;
export function parseCoupon(input: string): Result<string, Failure> {
const code = input.trim();
return couponFormat.test(code)
? ok(code)
: err({ kind: 'invalid', field: 'coupon', message: 'Enter a valid coupon.' });
}
export function lookupCoupon(code: string): Result<number, Failure> {
return code === 'SAVE10'
? ok(10)
: err({ kind: 'not-found', message: 'That coupon does not exist.' });
}
export function discountedTotal(percent: number): number {
return (subtotal * (100 - percent)) / 100;
}
/** R1: every dependent step gets its own guard clause. */
export function checkout(input: string): Result<number, Failure> {
const coupon = parseCoupon(input);
if (!coupon.ok) return coupon;
const percent = lookupCoupon(coupon.value);
if (!percent.ok) return percent;
return ok(discountedTotal(percent.value));
}
export function map<T, U, E>(result: Result<T, E>, transform: (value: T) => U): Result<U, E> {
return result.ok ? ok(transform(result.value)) : { ok: false, error: result.error };
}
export function andThen<T, U, E>(
result: Result<T, E>,
next: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? next(result.value) : { ok: false, error: result.error };
}
export function mapError<T, E, F>(result: Result<T, E>, transform: (error: E) => F): Result<T, F> {
return result.ok ? result : { ok: false, error: transform(result.error) };
}
export function unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
return result.ok ? result.value : fallback;
}
/** R2: the same checkout, with propagation moved into helpers you now maintain. */
export function checkoutWithCombinators(input: string): Result<number, Failure> {
return map(andThen(parseCoupon(input), lookupCoupon), discountedTotal);
}
// Stands in for an installed Result package so the lesson runs without a download. A real R3
// choice imports this class (and its async twin) from the library instead of defining it.
export class LibraryResult<T, E> {
readonly result: Result<T, E>;
private constructor(result: Result<T, E>) {
this.result = result;
}
static from<T, E>(result: Result<T, E>): LibraryResult<T, E> {
return new LibraryResult(result);
}
andThen<U>(next: (value: T) => Result<U, E>): LibraryResult<U, E> {
return new LibraryResult(andThen(this.result, next));
}
map<U>(transform: (value: T) => U): LibraryResult<U, E> {
return new LibraryResult(map(this.result, transform));
}
}
/** R3: the library's type is now the signature every caller above this one sees. */
export function checkoutThroughLibrary(input: string): LibraryResult<number, Failure> {
return LibraryResult.from(parseCoupon(input)).andThen(lookupCoupon).map(discountedTotal);
}
export type Effect<Requirements, E, A> = Readonly<{
requires: Requirements;
run: (requirements: Requirements) => Result<A, E>;
}>;
export type CheckoutServices = Readonly<{ pricing: string; payments: string }>;
export function checkoutAsEffect(input: string): Effect<CheckoutServices, Failure, number> {
return {
requires: { pricing: 'pricing-service', payments: 'payment-service' },
run: () => checkoutWithCombinators(input)
};
}
function runStep(
value: string,
step: (typeof steps)[number],
failureAt: FailureAt
): Result<string, Failure> {
return failureAt === step ? err(failureFor(step)) : ok(`${value} → ${step}`);
}
function runWithGuards(length: ChainLength, failureAt: FailureAt): Result<string, Failure> {
let value = 'cart';
for (const step of steps.slice(0, length)) {
const result = runStep(value, step, failureAt);
if (!result.ok) return result;
value = result.value;
}
return ok(value);
}
function runWithOwnCombinators(length: ChainLength, failureAt: FailureAt): Result<string, Failure> {
let result: Result<string, Failure> = ok('cart');
for (const step of steps.slice(0, length)) {
result = andThen(result, (value) => runStep(value, step, failureAt));
}
return result;
}
export function observe(
position: Position,
chainLength: ChainLength,
failureAt: FailureAt
): Observation {
const result =
position === 'hand-rolled'
? runWithGuards(chainLength, failureAt)
: runWithOwnCombinators(chainLength, failureAt);
const failureIndex =
failureAt === 'none' ? -1 : steps.indexOf(failureAt as (typeof steps)[number]);
const reachedFailure = failureIndex >= 0 && failureIndex < chainLength;
const completedSteps = reachedFailure ? failureIndex : chainLength;
const propagation =
position === 'hand-rolled'
? `${chainLength} operation${chainLength === 1 ? '' : 's'} need${chainLength === 1 ? 's' : ''} an explicit guard.`
: `${chainLength} operation${chainLength === 1 ? '' : 's'} share one propagation vocabulary.`;
const descriptions: Record<Position, { spread: string; tradeoff: string }> = {
'hand-rolled': {
spread: 'One function',
tradeoff: 'No dependency; every deeper call site repeats the check.'
},
'own-combinators': {
spread: 'Where helpers are used',
tradeoff: 'No dependency; async twins and error widening become your maintenance job.'
},
library: {
spread: 'Every signature above the call',
tradeoff:
'Combinators and edge cases are maintained elsewhere, but the type becomes a codebase commitment.'
},
'effect-system': {
spread: 'The whole effectful boundary',
tradeoff:
'Requirements, retries, and timeouts compose; adopting or leaving is a large rewrite.'
}
};
return {
position,
chainLength,
failureAt,
result: result.ok ? 'success' : 'failure',
completedSteps,
propagation,
spread: descriptions[position].spread,
tradeoff: descriptions[position].tradeoff
};
}
export const checkoutInputs = ['SAVE10', 'SAVE5', 'save 10!'] as const;
/** One line per input, printed identically by the Go program. */
export function describeCheckout(input: string, result: Result<number, Failure>): string {
return result.ok
? `${JSON.stringify(input)}: total ${result.value}`
: `${JSON.stringify(input)}: ${result.error.kind} (${result.error.message})`;
}
export function runExample(): string[] {
return checkoutInputs.map((input) => describeCheckout(input, checkoutWithCombinators(input)));
}
for (const line of runExample()) console.log(line);
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
)
const subtotal = 100
var couponFormat = regexp.MustCompile(`^[A-Z]+[0-9]{1,2}$`)
// Failure carries the same kinds as the TypeScript union: invalid, not-found, unavailable.
type Failure struct {
Kind string
Message string
}
func (f *Failure) Error() string { return f.Kind + " (" + f.Message + ")" }
func parseCoupon(input string) (string, error) {
code := strings.TrimSpace(input)
if !couponFormat.MatchString(code) {
return "", &Failure{Kind: "invalid", Message: "Enter a valid coupon."}
}
return code, nil
}
func lookupCoupon(code string) (int, error) {
if code == "SAVE10" {
return 10, nil
}
return 0, &Failure{Kind: "not-found", Message: "That coupon does not exist."}
}
func discountedTotal(percent int) int {
return subtotal * (100 - percent) / 100
}
// checkout is Go's standard position: the (value, error) pair is the result type,
// and each dependent step forwards its error explicitly.
func checkout(input string) (int, error) {
code, err := parseCoupon(input)
if err != nil {
return 0, err
}
percent, err := lookupCoupon(code)
if err != nil {
return 0, err
}
return discountedTotal(percent), nil
}
// Result is the helper you would own. Go methods cannot declare their own type parameters,
// so Map and AndThen are plain functions, and every standard-library call still returns a
// (value, error) pair that has to be converted with From.
type Result[T any] struct {
Value T
Err error
}
func From[T any](value T, err error) Result[T] {
return Result[T]{Value: value, Err: err}
}
func AndThen[T, U any](r Result[T], next func(T) (U, error)) Result[U] {
if r.Err != nil {
return Result[U]{Err: r.Err}
}
return From(next(r.Value))
}
func Map[T, U any](r Result[T], transform func(T) U) Result[U] {
if r.Err != nil {
return Result[U]{Err: r.Err}
}
return Result[U]{Value: transform(r.Value)}
}
func checkoutWithCombinators(input string) Result[int] {
return Map(AndThen(From(parseCoupon(input)), lookupCoupon), discountedTotal)
}
var checkoutInputs = []string{"SAVE10", "SAVE5", "save 10!"}
// describeCheckout prints the same line as the TypeScript example for each input.
func describeCheckout(input string, result Result[int]) string {
if result.Err != nil {
return strconv.Quote(input) + ": " + result.Err.Error()
}
return fmt.Sprintf("%s: total %d", strconv.Quote(input), result.Value)
}
func main() {
for _, input := range checkoutInputs {
fmt.Println(describeCheckout(input, checkoutWithCombinators(input)))
}
}
Save the TypeScript as results.ts and run node results.ts (Node
22.18 or later runs TypeScript directly). Save the Go as results.go and run go run results.go. Both print the same total for each coupon.
Standardize where the type can stay coherent.
A Result library becomes valuable when a team already has many Result signatures and wants one maintained vocabulary for mapping, async composition, and error widening. It becomes expensive when one isolated function imports a type that every caller now has to learn.
An effect system earns its spread when requirements, cancellation, retries, timeouts, and failure composition are one operational concern. If only one function needs a typed failure, the whole runtime is more architecture than solution.
Start small
Prove the caller needs explicit expected failure.
Adopt coherently
Let a library own helpers once the type already spreads.
Claim the runtime
Use effects when requirements and controls compose everywhere.
Build UIs?A UI state is a consumer of the chosen error vocabulary.
Where it already is in your components
A component often needs only a small local union: idle, loading, ready, and failure. Keep that state close to the view unless the application already has a shared Result vocabulary.
When you have to own it
When multiple data loaders need the same async composition and error normalization, a shared library may pay for itself. When the UI only needs to render one request’s cases, a local union is easier to explain and leave.
Languages make different parts of the tradeoff cheap.
Go’s (value, error)
The convention is already the ecosystem’s result type. A Result[T] wrapper means
converting at every call into the standard library and every package you import.
TypeScript libraries
A package can supply the missing helpers, but its Result type still becomes part of every signature that carries it.
See a TypeScript Result library ↗The helper is small. The adoption surface is not.
Async is not just a Promise around Result
An async helper must decide whether it is Promise<Result<T, E>>,
a library-specific async result, or an effect. It also needs rules for cancellation,
rejected promises, and a failure that happens before the Result exists.
Error types widen at joins
If one step returns Invalid and the next returns Unavailable,
the combined operation needs a union or a normalization rule. A helper that silently turns
both into Error has removed the information the Result was adopted to preserve.
unwrapOr can hide a decision
A fallback is useful at a boundary that has an honest default. In the middle of a workflow, it can turn a failed payment into a zero or an empty list and let invalid state continue.
Effects make everything part of the model
An effect system can make requirements and retries explicit, but it also changes how ordinary functions are called, tested, and taught. The runtime boundary is a feature and a commitment.
Half a codebase is its own cost
A shared abstraction is hardest when some modules return it and others return exceptions, nullable values, or raw promises. Choose a seam and document the translation instead of making every caller understand every dialect.
Let the codebase’s existing shape set the adoption boundary.
Choose R1 for a small, local expected-failure boundary. Choose R2 only when the helpers are still a deliberate local dialect and you are willing to maintain their async twins. Choose R3 when Result already spreads and the team benefits from a maintained vocabulary. Choose R4 when requirements and operational controls—not just failure values—need composition.
Keep the union.
Pay only for explicit cases the caller can handle.
Adopt one library.
Make the type and helper vocabulary consistent.
Consider effects.
Accept the larger runtime only when it solves a larger problem.
Choose the abstraction that can keep its promise.
Result types make failure visible. Combinators make propagation bearable. Libraries and effect systems make those choices shared. Each benefit arrives with a larger surface that future code must understand.
- Why
- Compose expected failure without losing the caller’s choice.
- What
- Adopt only as much Result machinery as the shared boundary can sustain.
- Constraint
- Every caller that meets the type must understand it the same way.
- Fallback
- Convert to a plain union or
(value, error)at the edge it cannot cross. - Reconsider when
- The abstraction spreads farther than the problem it solves.
Connections to follow nextRelated lessons
- Discriminated union results covers the shape itself and the early-return tax this lesson starts from.
- Go’s error protocol is a language where the standard convention answers a different set of questions.
- Errors across a boundary picks up where the result leaves the process and becomes a public response.
- Aggregate and partial failure asks what one result should say when a batch fails unevenly.