From 8b10592782e53566e4fb565b2934afaf234afd63 Mon Sep 17 00:00:00 2001 From: jcbkdev Date: Sat, 31 Oct 2026 20:47:31 +0100 Subject: [PATCH] feat: add match method to Result for handling both ok and error cases --- README.md | 43 ++++++++++++---- src/result.test.ts | 123 ++++++++++++++++++++++++++++++++++++++++++++- src/result.ts | 14 ++++++ 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0dbd7fc..bc0386e 100644 --- a/README.md +++ b/README.md @@ -38,26 +38,34 @@ if (result.isOk()) { } else { console.error(result.getError()); } + +// or handle both cases in one expression + +const message = result.match({ + ok: (age) => `Age is ${age}`, + error: (error) => error.message, +}); ``` ## Reference ### Creating a result -| Method | Description | -| --------------------- | -------------------------------------------------------- | -| `Result.ok()` | Creates a successful `Result`, with no value. | -| `Result.ok(value)` | Creates a successful result, holding a value. | -| `Result.error(error)` | Creates a failed result holding an `Error`. | +| Method | Description | +| --------------------- | --------------------------------------------------- | +| `Result.ok()` | Creates a successful `Result`, with no value. | +| `Result.ok(value)` | Creates a successful result, holding a value. | +| `Result.error(error)` | Creates a failed result holding an `Error`. | ### Inspecting a result -| Method | Returns | Description | -| ------------ | -------------------- | -------------------------------------------------- | -| `isOk()` | `boolean` | `true` if the result is a success. | -| `isError()` | `boolean` | `true` if the result is a failure. | -| `getValue()` | `T \| undefined` | The value of a success. `undefined` for a failure. | -| `getError()` | `Error \| undefined` | The error of a failure. `undefined` for a success. | +| Method | Returns | Description | +| ---------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `isOk()` | `boolean` | `true` if the result is a success. | +| `isError()` | `boolean` | `true` if the result is a failure. | +| `getValue()` | `T \| undefined` | The value of a success. `undefined` for a failure. | +| `getError()` | `Error \| undefined` | The error of a failure. `undefined` for a success. | +| `match({ ok, error })` | `O \| E` | Calls `ok(value): O` for a success or `error(error): E` for a failure, and returns what the called handler returns. | ## Notes @@ -73,6 +81,19 @@ if (result.isOk()) { /* ... */ } if (result.getValue()) { /* ... */ } ``` +### Using match + +`match` handles both outcomes in one expression. You have to provide both handlers, so you can't forget the error case, and you get a value back without writing an `if`/`else`: + +```ts +const message = parseAge(input).match({ + ok: (age) => `Age is ${age}`, + error: (error) => `Invalid input: ${error.message}`, +}); +``` + +Use `isOk()` / `isError()` instead when you need to return early or run statements in a branch rather than produce a value. + ### Using try/catch A function that returns a `Result` should not throw. To use code that can throw, such as the `URL` constructor, catch the exception and return it as a failure: diff --git a/src/result.test.ts b/src/result.test.ts index 8d3b899..a1603e2 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe, expectTypeOf } from 'vitest'; +import { expect, test, describe, expectTypeOf, vi } from 'vitest'; import { Result } from './result'; describe('Result', () => { @@ -88,4 +88,125 @@ describe('Result', () => { } }); }); + + describe('match', () => { + const createResult = (valid: boolean): Result => + valid ? Result.ok(10) : Result.error(new Error('message')); + + describe('on an ok result', () => { + test('calls only the ok handler, with the value', () => { + const ok = vi.fn((v: number) => v * 2); + const error = vi.fn((e: Error) => e.message); + + createResult(true).match({ ok, error }); + + expect(ok).toHaveBeenCalledTimes(1); + expect(ok).toHaveBeenCalledWith(10); + expect(error).not.toHaveBeenCalled(); + }); + + test('returns the ok handler return value', () => { + const output = createResult(true).match({ + ok: (v) => v * 2, + error: () => -1, + }); + + expect(output).toBe(20); + }); + }); + + describe('on an error result', () => { + test('calls only the error handler, with the error', () => { + const err = new Error('boom'); + const ok = vi.fn((v: number) => v); + const error = vi.fn((e: Error) => e.message); + const result: Result = Result.error(err); + + result.match({ ok, error }); + + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith(err); + expect(ok).not.toHaveBeenCalled(); + }); + + test('returns the error handler return value', () => { + const output = createResult(false).match({ + ok: () => 'ok', + error: (e) => `failed: ${e.message}`, + }); + + expect(output).toBe('failed: message'); + }); + }); + + describe('edge cases', () => { + test.each([0, '', false, undefined, null])( + 'routes falsy ok value %j to the ok handler', + (value) => { + const output = Result.ok(value).match({ + ok: () => 'ok', + error: () => 'error', + }); + + expect(output).toBe('ok'); + }, + ); + + test('passes undefined to ok for Result', () => { + const ok = vi.fn(); + const result: Result = Result.ok(); + + result.match({ ok, error: () => {} }); + + expect(ok).toHaveBeenCalledWith(undefined); + }); + + test('does not swallow errors thrown by a handler', () => { + expect(() => + createResult(true).match({ + ok: () => { + throw new Error('handler failed'); + }, + error: () => 0, + }), + ).toThrow('handler failed'); + }); + }); + + describe('types', () => { + test('infers handler parameter types', () => { + createResult(true).match({ + ok: (v) => { + expectTypeOf(v).toEqualTypeOf(); + }, + error: (e) => { + expectTypeOf(e).toEqualTypeOf(); + }, + }); + }); + + test('returns a union when handlers return different types', () => { + const output = createResult(true).match({ + ok: (v) => v, + error: (e) => e.message, + }); + + expectTypeOf(output).toEqualTypeOf(); + }); + + test('returns a single type when handlers agree', () => { + const output = createResult(true).match({ + ok: () => 'a', + error: () => 'b', + }); + + expectTypeOf(output).toEqualTypeOf(); + }); + + test('rejects handlers missing a branch', () => { + // @ts-expect-error: error handler is required + createResult(true).match({ ok: (v) => v }); + }); + }); + }); }); diff --git a/src/result.ts b/src/result.ts index 894caf6..9a2e89a 100644 --- a/src/result.ts +++ b/src/result.ts @@ -46,6 +46,20 @@ class ResultImpl { getError(): Error | undefined { return this.error; } + + match({ + ok, + error, + }: { + ok: (value: T) => O; + error: (value: Error) => E; + }): O | E { + if (this.isOk()) { + return ok(this.getValue() as T); + } else { + return error(this.getError() as Error); + } + } } export const Result = ResultImpl;