← Concepts & practices
Choice Errors, results, and recovery

Result types & combinators

How much of the codebase should adopt them?

A tagged result is easy to write. The harder decision is what happens after the second function returns one: do you add another guard, maintain your own helpers, adopt a library, or move the whole boundary into an effect system? Compare the commitment, not just the syntax.

The judgment to keep

A Result library is not merely a utility: its type appears in every signature above the call. Choose the smallest boundary that removes real cost, and adopt it consistently where it earns that spread.

TypeScriptGo One checkout path · four adoption positions
Start with the decision

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
results.ts · hand-rolled
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.

Four positions

Each step buys help by claiming more of the codebase.

R1

Hand-rolled, no combinators

A discriminated union and guard clauses. Zero dependencies, zero propagation magic.

Best when one boundary is enough.
R2

Hand-rolled, with your helpers

Own map, andThen, mapError, and unwrapOr.

Looks cheap until async doubles the surface.
R3

A Result library

Someone else maintains combinators and edge cases; your signatures adopt its type.

Useful when the codebase already speaks Result.
R4

An effect system

Errors, requirements, success, retries, and timeouts compose in one larger model.

Worth it when operational composition is the problem.
What each position claims
PositionDependenciesAsyncSpreadCost to leave
R1 · UnionNoneAwait, then guardOne functionLow
R2 · Own helpersNoneEvery helper needs a twinWhere helpers are usedMedium
R3 · LibraryOne packageUsually providedEvery signature above the callHigh
R4 · EffectRuntime + typeNative operatorsThe whole effectful boundaryVery high
Read the owned-combinator positionTypeScript · the surface you now maintain
results.ts · own combinators
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.

Change the commitment

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.

Your helpers

The first chain reads well; async twins, error widening, and edge cases become yours.

Where helpers are usedhow far the type spreads
2/3operations reached
failurepipeline outcome

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
results.ts
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.

Make the boundary explicit

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.

One TypeScript function returns one expected failure.
A TypeScript codebase already uses Result in most signatures and needs maintained async helpers.
A service needs typed requirements, retries, timeouts, and one shared runtime boundary.
Feedback stays on this page; it is not saved.
Compare the shapes

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

TypeScript · own helpers

results.ts · own combinators
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

Go · the standard pair

results.go · (value, error)
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

Go · a Result[T] you own

results.go · owned Result[T]
// 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
results.ts · library boundary
/** 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
TypeScript
results.ts
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);
Go
results.go
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.

A production boundary

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.

Local function

Start small

Prove the caller needs explicit expected failure.

Shared vocabulary

Adopt coherently

Let a library own helpers once the type already spreads.

Operational boundary

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.

Recognize it elsewhere

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.

Read Error handling and Go ↗

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 parts to watch

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.

Make the call

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.

One boundary

Keep the union.

Pay only for explicit cases the caller can handle.

Many shared signatures

Adopt one library.

Make the type and helper vocabulary consistent.

Requirements + controls

Consider effects.

Accept the larger runtime only when it solves a larger problem.

Take the idea with you

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
Explore more concepts & practices →