Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strong-combinators-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@byteslice/result': minor
---

Added `unwrapOr`, `unwrapOrElse`, `map`, and `mapFailure` functions for extracting and transforming results.
2 changes: 1 addition & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@
".next",
".gitignore"
],
"words": ["byteslice"]
"words": ["byteslice", "combinator", "combinators"]
}
78 changes: 78 additions & 0 deletions packages/result/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<User, Error> → Result<string, Error>
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.
Expand Down
126 changes: 126 additions & 0 deletions packages/result/src/result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -152,3 +156,125 @@ describe('expect', () => {
}
})
})

describe('unwrapOr', () => {
it('should return data for a success', () => {
const result: Result<number> = { data: 1 }

expect(unwrapOr(result, 2)).toBe(1)
})

it('should return the fallback for a failure', () => {
const result: Result<number> = { failure: error }

expect(unwrapOr(result, 2)).toBe(2)
})

it('should return falsy data rather than the fallback', () => {
const result: Result<number> = { data: 0 }

expect(unwrapOr(result, 2)).toBe(0)
})
})

describe('unwrapOrElse', () => {
it('should return data for a success', () => {
const result: Result<number> = { data: 1 }

expect(unwrapOrElse(result, () => 2)).toBe(1)
})

it('should compute the fallback from the failure', () => {
const result: Result<number> = { failure: error }

expect(unwrapOrElse(result, (failure) => failure.message.length)).toBe(
message.length,
)
})

it('should not invoke the callback for a success', () => {
const result: Result<number> = { data: 1 }
let called = false

unwrapOrElse(result, () => {
called = true
return 2
})

expect(called).toBeFalse()
})

it('should receive a custom failure', () => {
const result: Result<number, { error: Error; custom: boolean }> = {
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<number> = { data: 2 }
const mapped = map(result, (data) => data * 2)

expect(unwrap(mapped)).toBe(4)
})

it('should leave a failure untouched', () => {
const result: Result<number> = { 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<number> = { 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<number> = { failure: error }
const mapped = mapFailure(result, () => fallback)

expect(mapped.failure).toBe(fallback)
})

it('should support mapping to a custom failure', () => {
const result: Result<number> = { 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<number> = { data: 1 }
const mapped = mapFailure(result, () => fallback)

expect(unwrap(mapped)).toBe(1)
})

it('should not invoke the callback for a success', () => {
const result: Result<number> = { data: 1 }
let called = false

mapFailure(result, () => {
called = true
return fallback
})

expect(called).toBeFalse()
})
})
67 changes: 67 additions & 0 deletions packages/result/src/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,73 @@ export function expect<S, F extends FailureOption = Error>(
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<S, F extends FailureOption = Error>(
result: Result<S, F>,
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<S, F extends FailureOption = Error>(
result: Result<S, F>,
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<S, U, F extends FailureOption = Error>(
result: Result<S, F>,
fn: (data: S) => U,
): Result<U, F> {
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<S, F extends FailureOption, G extends FailureOption>(
result: Result<S, F>,
fn: (failure: F) => G,
): Result<S, G> {
if (isSuccess(result)) {
return result
}

return { failure: fn(result.failure) }
}

/** Wraps operation with structured result (success and failure states). */
export async function withResult<S, F extends FailureOption = Error>(
operation: () => S | Promise<S>,
Expand Down
Loading