diff --git a/.changeset/lovely-rocks-collect.md b/.changeset/lovely-rocks-collect.md new file mode 100644 index 0000000..662eb96 --- /dev/null +++ b/.changeset/lovely-rocks-collect.md @@ -0,0 +1,5 @@ +--- +"@byteslice/result": minor +--- + +Modified failure option to permit object with `error` property. diff --git a/.changeset/wise-ravens-dance.md b/.changeset/wise-ravens-dance.md new file mode 100644 index 0000000..c5b12bd --- /dev/null +++ b/.changeset/wise-ravens-dance.md @@ -0,0 +1,5 @@ +--- +"@byteslice/result": minor +--- + +Renamed optional `withResult` parameter to `options`. diff --git a/.flox/env/manifest.lock b/.flox/env/manifest.lock index d9869c2..3aa88a3 100644 --- a/.flox/env/manifest.lock +++ b/.flox/env/manifest.lock @@ -3,10 +3,6 @@ "manifest": { "version": 1, "install": { - "_1password": { - "pkg-path": "_1password", - "version": "2.30.3" - }, "bun": { "pkg-path": "bun", "version": "1.2.4" @@ -15,141 +11,16 @@ "hook": { "on-activate": " echo \"📦 Installing dependencies...\"\n bun install --frozen-lockfile\n\n echo -e \"\\n🚀 Development Commands\"\n echo -e \"- Run all tasks\\t\\t\\033[34mbun dev\\033[0m\"\n echo -e \"- Start playground app\\t\\033[34mbun dev:playground\\033[0m\"\n" }, - "profile": {}, "options": { "systems": [ "aarch64-darwin", "aarch64-linux", "x86_64-darwin", "x86_64-linux" - ], - "allow": { - "licenses": [] - }, - "semver": {} + ] } }, "packages": [ - { - "attr_path": "_1password", - "broken": false, - "derivation": "/nix/store/9290cl1fzxz3wzxwlwiyw5q1rnv78f3n-1password-cli-2.30.3.drv", - "description": "1Password command-line tool", - "install_id": "_1password", - "license": "Unfree", - "locked_url": "https://github.com/flox/nixpkgs?rev=6607cf789e541e7873d40d3a8f7815ea92204f32", - "name": "1password-cli-2.30.3", - "pname": "_1password", - "rev": "6607cf789e541e7873d40d3a8f7815ea92204f32", - "rev_count": 767267, - "rev_date": "2025-03-13T07:39:42Z", - "scrape_date": "2025-03-14T00:31:05Z", - "stabilities": [ - "staging", - "unstable" - ], - "unfree": true, - "version": "2.30.3", - "outputs_to_install": [ - "out" - ], - "outputs": { - "out": "/nix/store/n0jgflq9hkylgfvxfrc9ris4lgbza738-1password-cli-2.30.3" - }, - "system": "aarch64-darwin", - "group": "toplevel", - "priority": 5 - }, - { - "attr_path": "_1password", - "broken": false, - "derivation": "/nix/store/wwa0j53z5afs65r8sl9vh8689rwzjffz-1password-cli-2.30.3.drv", - "description": "1Password command-line tool", - "install_id": "_1password", - "license": "Unfree", - "locked_url": "https://github.com/flox/nixpkgs?rev=6607cf789e541e7873d40d3a8f7815ea92204f32", - "name": "1password-cli-2.30.3", - "pname": "_1password", - "rev": "6607cf789e541e7873d40d3a8f7815ea92204f32", - "rev_count": 767267, - "rev_date": "2025-03-13T07:39:42Z", - "scrape_date": "2025-03-14T00:31:05Z", - "stabilities": [ - "staging", - "unstable" - ], - "unfree": true, - "version": "2.30.3", - "outputs_to_install": [ - "out" - ], - "outputs": { - "out": "/nix/store/w2qm1zj4hip9xijsnvjin2fiwqx4kdz9-1password-cli-2.30.3" - }, - "system": "aarch64-linux", - "group": "toplevel", - "priority": 5 - }, - { - "attr_path": "_1password", - "broken": false, - "derivation": "/nix/store/3avy67fd3w6aaxwfls2rip1s0mmmfj40-1password-cli-2.30.3.drv", - "description": "1Password command-line tool", - "install_id": "_1password", - "license": "Unfree", - "locked_url": "https://github.com/flox/nixpkgs?rev=6607cf789e541e7873d40d3a8f7815ea92204f32", - "name": "1password-cli-2.30.3", - "pname": "_1password", - "rev": "6607cf789e541e7873d40d3a8f7815ea92204f32", - "rev_count": 767267, - "rev_date": "2025-03-13T07:39:42Z", - "scrape_date": "2025-03-14T00:31:05Z", - "stabilities": [ - "staging", - "unstable" - ], - "unfree": true, - "version": "2.30.3", - "outputs_to_install": [ - "out" - ], - "outputs": { - "out": "/nix/store/c00fjp4hykbgy2ddpqg8rgpc3k1mjc3z-1password-cli-2.30.3" - }, - "system": "x86_64-darwin", - "group": "toplevel", - "priority": 5 - }, - { - "attr_path": "_1password", - "broken": false, - "derivation": "/nix/store/bqwdcv4i0kyahwspgfw8gxisrgqzcxgf-1password-cli-2.30.3.drv", - "description": "1Password command-line tool", - "install_id": "_1password", - "license": "Unfree", - "locked_url": "https://github.com/flox/nixpkgs?rev=6607cf789e541e7873d40d3a8f7815ea92204f32", - "name": "1password-cli-2.30.3", - "pname": "_1password", - "rev": "6607cf789e541e7873d40d3a8f7815ea92204f32", - "rev_count": 767267, - "rev_date": "2025-03-13T07:39:42Z", - "scrape_date": "2025-03-14T00:31:05Z", - "stabilities": [ - "staging", - "unstable" - ], - "unfree": true, - "version": "2.30.3", - "outputs_to_install": [ - "out" - ], - "outputs": { - "out": "/nix/store/dl9afsjn3qqzdd90k15l4qvkrqyca08y-1password-cli-2.30.3" - }, - "system": "x86_64-linux", - "group": "toplevel", - "priority": 5 - }, { "attr_path": "bun", "broken": false, diff --git a/.flox/env/manifest.toml b/.flox/env/manifest.toml index 3653bdd..1e49aaf 100644 --- a/.flox/env/manifest.toml +++ b/.flox/env/manifest.toml @@ -1,8 +1,6 @@ version = 1 [install] -_1password.pkg-path = "_1password" -_1password.version = "2.30.3" bun.pkg-path = "bun" bun.version = "1.2.4" diff --git a/packages/result/README.md b/packages/result/README.md index b7a3c26..ef2c6f4 100644 --- a/packages/result/README.md +++ b/packages/result/README.md @@ -8,7 +8,6 @@ This package enables developers to clearly represent both _success_ and _failure ## Table of Contents -- [Installation](#installation) - [Motivation](#motivation) - [Overview](#overview) - [Usage](#usage) @@ -16,18 +15,6 @@ This package enables developers to clearly represent both _success_ and _failure - [Contributing](#contributing) - [License](#license) -## Installation - -```bash -npm install @byteslice/result -# or -yarn add @byteslice/result -# or -pnpm add @byteslice/result -# or -bun add @byteslice/result -``` - ## Motivation To fully understand the purpose and application of this package, it's essential to provide some context. @@ -47,7 +34,7 @@ TypeScript—while providing excellent type safety—lacks a built-in mechanism Consider the following function. While the implementation indicates that an exception could be thrown, the type signature fails to convey this information. ```ts function fetchUser(id: string): User { - throw new Error("Oh, no! Mr. Bill!") + throw new Error('Oh no, Mr. Bill!') } ``` @@ -63,9 +50,9 @@ Instead of an operation simply returning a value (indicating success) or throwin ## Overview -`@byteslice/result` provides two key exports: +`@byteslice/result` provides two exports: -1. **`Result`** – A discriminated union type representing either: +1. **`Result`** – A discriminated union type representing either: - **Success**: `{ data: S }` - **Failure**: `{ failure: F }` @@ -74,114 +61,137 @@ Instead of an operation simply returning a value (indicating success) or throwin - Catches any thrown exception. - Returns a **success** or **failure** object rather than throwing. -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. +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 ### Basic Example ```ts -import { withResult } from '@byteslice/result'; +import { withResult } from '@byteslice/result' +// function signature does not indicate an exception may occur async function fetchData(): Promise { - // Imagine this might fail - return "Data fetched successfully!"; + throw new Error('The dog refused to fetch') } async function main() { const result = await withResult( + // operation () => fetchData(), - (error) => error // Pass the error through as-is (default) - ); + // onError + (error) => new Error('Could not fetch data', { cause: error }) + ) - if (!result.failure) { - console.log('Success:', result.data); + // check for failure + if (result.failure) { + console.error(result.failure) } else { - console.error('Failure:', result.failure); + // result is a success + // data property is now available + console.log(result.data) } } -main(); +main() ``` -In this example: -- The `fetchData` function may throw. -- `withResult` catches any thrown exceptions and calls `onError`, returning a `failure` object if something goes wrong. -- The caller only needs to check if `result` contains a `data` or a `failure` property. +🔎  Let's examine `withResult` further: +- The first parameter (`operation`) wraps a function to be executed when `withResult` is called. + - If the provided function throws an exception, it is coerced to an error (as necessary). +- The second parameter (`onError`) receives this error as its sole argument and returns a `FailureOption`—either an `Error` or a `FailureCase` (an object with an `error` property). +- The `Result` returned from `withResult` depends on the result of the `operation`. + - If _successful_, the returned `Result` will be type `Success` and contain the output of the executed function in its `data` property. + - If _unsuccessful_, the returned `Result` will be type `Failure` and contain the `FailureOption` in its `failure` property. + +To ensure failure states are handled, the `failure` property of the `Result` must be examined before the `data` property (and its strongly-typed contents) can be accessed. -### Custom Failure Types +> 💡 In the example above, `onError` returns a bespoke `Error` while **maintaining the stack trace** of the original error via [cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause). -By default, the failure type (`F`) is `Error`, but you can define your own custom failure structure: +### Custom Failure + +By default, the `Failure` type of `Result` contains a `failure` property of `Error`. + +However, you can define your own custom `failure`—as long as it is an object with an `error` property of type `Error`. This ensures the error is available, while permitting the flexibility to add any other fields. ```ts -import { withResult, Result } from '@byteslice/result'; +import { withResult, Result } from '@byteslice/result' -interface MyCustomFailure { - type: 'NETWORK_ERROR' | 'VALIDATION_ERROR'; - message: string; +type CustomFailure = { + // required property + error: Error + // custom property + type: 'NETWORK_ERROR' | 'VALIDATION_ERROR' } -async function fetchUser(): Promise> { - return withResult( +type CustomSuccess = { name: string } + +async function fetchUser(): Promise> { + return await withResult( async () => { - // Potentially throwing code - return { name: 'Alice' }; + // function call may throw an exception + const name = await db.getName() + + return { name } }, - (error) => ({ - type: 'NETWORK_ERROR', - message: error.message - }) - ); + // onError returns custom failure + (error) => ({ error, type: 'NETWORK_ERROR' }) + ) } async function main() { - const result = await fetchUser(); + const result = await fetchUser() - if (!result.failure) { - console.log('User:', result.data.name); + if (result.failure) { + console.warn('This type of error occurred:', result.failure.type) } else { - console.log('Failed with:', result.failure); + console.log(result.data.name) } } + +main() ``` -### Using `onException` Hook +### Hook: `onException` -You can optionally provide an `onException` hook to transform or log the original exception before `onError` is called: +You can optionally provide an `onException` hook to transform the original exception into an error before it is passed to `onError`. This is a great spot for logging or returning custom errors based on the type of exception. ```ts -import { withResult } from '@byteslice/result'; - -async function riskyOperation() { - throw new Error("Something unexpected occurred!"); -} +import { withResult } from '@byteslice/result' async function main() { const result = await withResult( + // operation may throw an exception () => riskyOperation(), - (err) => ({ type: 'CUSTOM_FAILURE', message: err.message }), + // onError receives error returned from onException + (err) => (err), { onException: (ex) => { - // Log, transform, or capture `ex` before it's passed to onError - console.error('Caught exception:', ex); - return new Error('Wrapped exception details'); + // log thrown exception + console.warn('Caught exception:', ex) + + // return known error + if (err instanceof CustomError) { + return err + } + + // return default error + return new Error('Something unexpected occurred') } } - ); + ) - if (!result.failure) { - console.log('Operation succeeded:', result.data); + if (result.failure) { + console.error(result.failure) } else { - console.warn('Operation failed:', result.failure); + console.log(result.data) } } -main(); +main() ``` -In this scenario: -- `onException` is called first, receiving the thrown value (`ex`), and returns an `Error`. -- That `Error` is then passed to your `onError` function. +If no `onException` hook is provided, then any thrown exceptions are handled by an internal `ensureError` function. As the name implies, it ensures the `onError` hook receives a valid error. ## Contributing diff --git a/packages/result/src/result.test.ts b/packages/result/src/result.test.ts index 3a83976..fd8beb2 100644 --- a/packages/result/src/result.test.ts +++ b/packages/result/src/result.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'bun:test' import { withResult } from './result' +const message = 'uh-oh' +const error = new Error(message) +const fallback = new Error('Something went wrong') + describe('withResult', () => { describe.each([ { @@ -19,14 +23,12 @@ describe('withResult', () => { expect(result.failure).toBeUndefined() - if (!result.failure) { + if (result.failure === undefined) { expect(result.data).toBeTrue() } }) }) - const error = new Error('uh-oh') - describe.each([ { label: 'should wrap synchronous operation', @@ -45,6 +47,55 @@ describe('withResult', () => { const result = await withResult(operation, (err) => err) expect(result.failure).toEqual(error) + expect('data' in result).toBe(false) + }) + }) + + describe('ensureError', () => { + it('should wrap non-error exception', async () => { + const result = await withResult( + () => { + throw message + }, + (err) => err, + ) + + expect(result.failure).toEqual(fallback) + expect('data' in result).toBe(false) + }) + }) + + describe('onException', () => { + it('should wrap non-error exception', async () => { + const result = await withResult( + () => { + throw message + }, + (err) => err, + { + onException: (ex) => { + expect(ex).toBe(message) + return error + }, + }, + ) + + expect(result.failure).toEqual(error) + expect('data' in result).toBe(false) + }) + }) + + describe('FailureOption', () => { + it('should permit custom failure', async () => { + const result = await withResult( + () => { + throw error + }, + (err) => ({ error: err, custom: true }), + ) + + expect(result.failure).toEqual({ error: error, custom: true }) + expect('data' in result).toBe(false) }) }) }) diff --git a/packages/result/src/result.ts b/packages/result/src/result.ts index c4cda1b..262b26d 100644 --- a/packages/result/src/result.ts +++ b/packages/result/src/result.ts @@ -7,11 +7,7 @@ type Failure = { failure: T } -type FailureCase = { - type: string -} - -type FailureOption = FailureCase | Error +type FailureOption = Error | { error: Error } export type Result = Success | Failure @@ -23,14 +19,14 @@ function ensureError(ex: unknown): Error { export async function withResult( operation: () => S | Promise, onError: (error: Error) => F, - hooks?: { + options?: { onException?: (ex: unknown) => Error }, ): Promise> { try { return { data: await operation() } } catch (ex) { - const error = hooks?.onException?.(ex) ?? ensureError(ex) + const error = options?.onException?.(ex) ?? ensureError(ex) return { failure: onError(error) } } }