From dc78c5e2d275b827481650935fc094143f0b5efb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 28 Jun 2026 15:12:24 +1000 Subject: [PATCH] feat(result): add unwrapOr, unwrapOrElse, map, and mapFailure Adds Rust-inspired combinators to complement unwrap/expect: - unwrapOr / unwrapOrElse: total, non-throwing extractors that return a fallback value (eager) or compute one from the failure (lazy). - map / mapFailure: transform the success data or the failure while passing the other state through untouched. --- .changeset/strong-combinators-map.md | 5 ++ cspell.json | 2 +- packages/result/README.md | 78 +++++++++++++++++ packages/result/src/result.test.ts | 126 +++++++++++++++++++++++++++ packages/result/src/result.ts | 67 ++++++++++++++ 5 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 .changeset/strong-combinators-map.md diff --git a/.changeset/strong-combinators-map.md b/.changeset/strong-combinators-map.md new file mode 100644 index 0000000..86f0ccd --- /dev/null +++ b/.changeset/strong-combinators-map.md @@ -0,0 +1,5 @@ +--- +'@byteslice/result': minor +--- + +Added `unwrapOr`, `unwrapOrElse`, `map`, and `mapFailure` functions for extracting and transforming results. diff --git a/cspell.json b/cspell.json index 06b9196..39a0271 100644 --- a/cspell.json +++ b/cspell.json @@ -18,5 +18,5 @@ ".next", ".gitignore" ], - "words": ["byteslice"] + "words": ["byteslice", "combinator", "combinators"] } diff --git a/packages/result/README.md b/packages/result/README.md index a35d9f2..3d826bd 100644 --- a/packages/result/README.md +++ b/packages/result/README.md @@ -64,6 +64,12 @@ Instead of an operation simply returning a value (indicating success) or throwin 3. **`unwrap`** / **`expect`** – Escape hatches (inspired by Rust) that return the success `data` directly, or throw if the `Result` is a failure. +4. **`unwrapOr`** / **`unwrapOrElse`** – Total (non-throwing) extractors that return + the success `data`, or a fallback when the `Result` is a failure. + +5. **`map`** / **`mapFailure`** – Combinators (inspired by Rust) that transform the + success `data` or the `failure` while passing the other state through untouched. + This pattern is particularly helpful when you want to **avoid using try/catch** directly in your code, or if you need a standardized way to capture failure details. ## Usage @@ -232,6 +238,78 @@ const config = expect(result, 'config should be present') > ⚠️ Like their Rust counterparts, `unwrap` and `expect` trade type safety for convenience. Reach for them only when a failure genuinely represents an unrecoverable state. +### Fallbacks: `unwrapOr` and `unwrapOrElse` + +When a failure should resolve to a sensible default rather than an exception, `unwrapOr` and `unwrapOrElse` return the success `data` or a fallback—and never throw. + +`unwrapOr` takes an eager fallback value. + +```ts +import { unwrapOr, withResult } from '@byteslice/result' + +const result = await withResult( + () => loadTimeout(), + (error) => error, +) + +// returns the loaded timeout, or 5000 on failure +const timeout = unwrapOr(result, 5000) +``` + +`unwrapOrElse` takes a function that computes the fallback from the `failure`. It is only invoked when the result is a failure, making it the lazy counterpart to `unwrapOr`. + +```ts +import { unwrapOrElse, withResult } from '@byteslice/result' + +const result = await withResult( + () => loadConfig(), + (error) => error, +) + +// computes a fallback from the failure, only when needed +const config = unwrapOrElse(result, (failure) => { + console.warn('Falling back to defaults:', failure.message) + return defaultConfig +}) +``` + +### Transforming: `map` and `mapFailure` + +`map` and `mapFailure` transform one side of a `Result` while passing the other side through untouched, letting you compose transformations without manually unpacking and re-packing the `Result`. + +`map` transforms the success `data`. A failure is returned unchanged, and the mapping function is not invoked. + +```ts +import { map, withResult } from '@byteslice/result' + +const result = await withResult( + () => fetchUser(), + (error) => error, +) + +// Result → Result +const name = map(result, (user) => user.name) +``` + +> 💡 The mapping function is assumed to be infallible (it cannot itself fail). If your transformation may throw, wrap it in `withResult` instead. + +`mapFailure` transforms the `failure`. A success is returned unchanged, and the mapped failure must itself be a valid failure—either an `Error` or an object with an `error` property. + +```ts +import { mapFailure, withResult } from '@byteslice/result' + +const result = await withResult( + () => fetchUser(), + (error) => error, +) + +// enrich the failure with additional context +const tagged = mapFailure(result, (error) => ({ + error, + type: 'NETWORK_ERROR' as const, +})) +``` + ## Contributing Please see [CONTRIBUTING.md](https://github.com/ByteSliceHQ/byteslice/blob/main/CONTRIBUTE.md) for details. diff --git a/packages/result/src/result.test.ts b/packages/result/src/result.test.ts index cb9b453..57886bd 100644 --- a/packages/result/src/result.test.ts +++ b/packages/result/src/result.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test' import { type Result, expect as expectResult, + map, + mapFailure, unwrap, + unwrapOr, + unwrapOrElse, withResult, } from './result' @@ -152,3 +156,125 @@ describe('expect', () => { } }) }) + +describe('unwrapOr', () => { + it('should return data for a success', () => { + const result: Result = { data: 1 } + + expect(unwrapOr(result, 2)).toBe(1) + }) + + it('should return the fallback for a failure', () => { + const result: Result = { failure: error } + + expect(unwrapOr(result, 2)).toBe(2) + }) + + it('should return falsy data rather than the fallback', () => { + const result: Result = { data: 0 } + + expect(unwrapOr(result, 2)).toBe(0) + }) +}) + +describe('unwrapOrElse', () => { + it('should return data for a success', () => { + const result: Result = { data: 1 } + + expect(unwrapOrElse(result, () => 2)).toBe(1) + }) + + it('should compute the fallback from the failure', () => { + const result: Result = { failure: error } + + expect(unwrapOrElse(result, (failure) => failure.message.length)).toBe( + message.length, + ) + }) + + it('should not invoke the callback for a success', () => { + const result: Result = { data: 1 } + let called = false + + unwrapOrElse(result, () => { + called = true + return 2 + }) + + expect(called).toBeFalse() + }) + + it('should receive a custom failure', () => { + const result: Result = { + failure: { error, custom: true }, + } + + expect(unwrapOrElse(result, (failure) => (failure.custom ? 1 : 2))).toBe(1) + }) +}) + +describe('map', () => { + it('should transform the data of a success', () => { + const result: Result = { data: 2 } + const mapped = map(result, (data) => data * 2) + + expect(unwrap(mapped)).toBe(4) + }) + + it('should leave a failure untouched', () => { + const result: Result = { failure: error } + const mapped = map(result, (data) => data * 2) + + expect(mapped.failure).toBe(error) + }) + + it('should not invoke the callback for a failure', () => { + const result: Result = { failure: error } + let called = false + + map(result, (data) => { + called = true + return data + }) + + expect(called).toBeFalse() + }) +}) + +describe('mapFailure', () => { + it('should transform the failure of a failure', () => { + const result: Result = { failure: error } + const mapped = mapFailure(result, () => fallback) + + expect(mapped.failure).toBe(fallback) + }) + + it('should support mapping to a custom failure', () => { + const result: Result = { failure: error } + const mapped = mapFailure(result, (failure) => ({ + error: failure, + custom: true, + })) + + expect(mapped.failure).toEqual({ error, custom: true }) + }) + + it('should leave a success untouched', () => { + const result: Result = { data: 1 } + const mapped = mapFailure(result, () => fallback) + + expect(unwrap(mapped)).toBe(1) + }) + + it('should not invoke the callback for a success', () => { + const result: Result = { data: 1 } + let called = false + + mapFailure(result, () => { + called = true + return fallback + }) + + expect(called).toBeFalse() + }) +}) diff --git a/packages/result/src/result.ts b/packages/result/src/result.ts index f4250ef..454913c 100644 --- a/packages/result/src/result.ts +++ b/packages/result/src/result.ts @@ -61,6 +61,73 @@ export function expect( throw new Error(message, { cause: getError(result.failure) }) } +/** + * Returns the success data of a result, or a fallback value if it is a failure. + * + * Inspired by Rust's `Result::unwrap_or`, this never throws. + */ +export function unwrapOr( + result: Result, + fallback: S, +): S { + if (isSuccess(result)) { + return result.data + } + + return fallback +} + +/** + * Returns the success data of a result, or computes a fallback from the failure. + * + * Inspired by Rust's `Result::unwrap_or_else`, this never throws and only + * invokes `fn` when the result is a failure. + */ +export function unwrapOrElse( + result: Result, + fn: (failure: F) => S, +): S { + if (isSuccess(result)) { + return result.data + } + + return fn(result.failure) +} + +/** + * Transforms the success data of a result, leaving a failure untouched. + * + * Inspired by Rust's `Result::map`. The mapping function is assumed to be + * infallible; if it can fail, reach for `withResult` instead. + */ +export function map( + result: Result, + fn: (data: S) => U, +): Result { + if (isSuccess(result)) { + return { data: fn(result.data) } + } + + return result +} + +/** + * Transforms the failure of a result, leaving success data untouched. + * + * Inspired by Rust's `Result::map_err`. The mapped failure must itself be a + * valid `FailureOption` (an `Error` or an object with an `error` property). + */ +export function mapFailure( + result: Result, + fn: (failure: F) => G, +): Result { + if (isSuccess(result)) { + return result + } + + return { failure: fn(result.failure) } +} + /** Wraps operation with structured result (success and failure states). */ export async function withResult( operation: () => S | Promise,