diff --git a/src/index.ts b/src/index.ts index 00dcdc8b..255202ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { Result, ok, Ok, err, Err, fromThrowable, safeTry } from './result' +export { Result, ok, Ok, err, Err, TaggedError, fromThrowable, safeTry } from './result' export { ResultAsync, okAsync, diff --git a/src/result-async.ts b/src/result-async.ts index 20120d2b..ae4fad1b 100644 --- a/src/result-async.ts +++ b/src/result-async.ts @@ -7,7 +7,7 @@ import type { MembersToUnion, } from './result' -import { Err, Ok, Result } from './' +import { Err, Ok, Result, TaggedError } from './' import { combineResultAsyncList, combineResultAsyncListWithAllErrors, @@ -199,6 +199,34 @@ export class ResultAsync implements PromiseLike> { ) } + catchTags>( + handlers: Handlers, + ): ResultAsync, E | CatchTagsHandlerErrTypes> + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + catchTags(handlers: any): any { + return new ResultAsync( + this._promise.then(async (res: Result) => { + if (res.isOk()) { + return new Ok(res.value) + } + + if (res.error instanceof TaggedError) { + const handler = handlers[res.error._tag] as + | (( + error: TaggedErrorUnion, + ) => Result | ResultAsync) + | undefined + if (handler !== undefined) { + const handledResult = handler(res.error) + return handledResult instanceof ResultAsync ? handledResult._promise : handledResult + } + } + + return new Err(res.error) + }), + ) + } + match(ok: (t: T) => A, _err: (e: E) => B): Promise { return this._promise.then((res) => res.match(ok, _err)) } @@ -336,3 +364,34 @@ type TraverseWithAllErrorsAsync = TraverseAsync< // Converts a reaodnly array into a writable array type Writable = T extends ReadonlyArray ? [...T] : T + +type TaggedErrorUnion = TaggedError +type CatchTagsHandlers = { + [K in string]?: ( + error: TaggedErrorUnion, + ) => Result | ResultAsync +} +type CatchTagsHandlersFor = Partial< + { + [K in Extract['_tag'], string>]: ( + error: Extract, { _tag: K }>, + ) => Result | ResultAsync + } +> +type CatchTagsHandlerResultUnion = { + [K in keyof Handlers]: Handlers[K] extends (...args: readonly unknown[]) => infer R ? R : never +}[keyof Handlers] +type CatchTagsHandlerResultSyncUnion = Extract< + CatchTagsHandlerResultUnion, + Result +> +type CatchTagsHandlerResultAsyncUnion = Extract< + CatchTagsHandlerResultUnion, + ResultAsync +> +type CatchTagsHandlerOkTypes = + | InferOkTypes> + | InferAsyncOkTypes> +type CatchTagsHandlerErrTypes = + | InferErrTypes> + | InferAsyncErrTypes> diff --git a/src/result.ts b/src/result.ts index ad447caa..185c3178 100644 --- a/src/result.ts +++ b/src/result.ts @@ -61,6 +61,31 @@ export namespace Result { export type Result = Ok | Err +type TaggedErrorUnion = TaggedError +type CatchTagsHandlers = { + [K in string]?: (error: TaggedErrorUnion) => Result +} +type CatchTagsHandlersFor = Partial< + { + [K in Extract['_tag'], string>]: ( + error: Extract, { _tag: K }>, + ) => Result + } +> +type CatchTagsHandlerResultUnion = { + [K in keyof Handlers]: Handlers[K] extends (...args: readonly unknown[]) => infer R ? R : never +}[keyof Handlers] + +export class TaggedError extends Error { + readonly _tag: Tag + + constructor(tag: Tag, message?: string) { + super(message) + this._tag = tag + this.name = this.constructor.name + } +} + export function ok(value: T): Ok export function ok(value: void): Ok export function ok(value: T): Ok { @@ -232,6 +257,16 @@ interface IResult { ): Result | T, InferErrTypes> orElse(f: (e: E) => Result): Result + /** + * Catches tagged errors by matching against the `_tag` property. + */ + catchTags>( + handlers: Handlers, + ): Result< + T | InferOkTypes>, + E | InferErrTypes> + > + /** * Similar to `map` Except you must return a new `Result`. * @@ -367,6 +402,15 @@ export class Ok implements IResult { return ok(this.value) } + catchTags>( + _handlers: Handlers, + ): Result< + T | InferOkTypes>, + E | InferErrTypes> + > { + return ok(this.value) + } + asyncAndThen(f: (t: T) => ResultAsync): ResultAsync { return f(this.value) } @@ -396,8 +440,10 @@ export class Ok implements IResult { safeUnwrap(): Generator, T> { const value = this.value - /* eslint-disable-next-line require-yield */ return (function* () { + for (const _err of [] as Err[]) { + yield _err + } return value })() } @@ -410,8 +456,10 @@ export class Ok implements IResult { throw createNeverThrowError('Called `_unsafeUnwrapErr` on an Ok', this, config) } - // eslint-disable-next-line @typescript-eslint/no-this-alias, require-yield *[Symbol.iterator](): Generator, T> { + for (const _err of [] as Err[]) { + yield _err + } return this.value } } @@ -471,6 +519,31 @@ export class Err implements IResult { return f(this.error) } + catchTags>( + handlers: Handlers, + ): Result< + T | InferOkTypes>, + E | InferErrTypes> + > { + if (this.error instanceof TaggedError) { + const keyedHandlers = handlers as CatchTagsHandlers + const handler = keyedHandlers[this.error._tag] as + | ((error: TaggedErrorUnion) => Result) + | undefined + if (handler !== undefined) { + return handler(this.error as TaggedErrorUnion) as Result< + T | InferOkTypes>, + E | InferErrTypes> + > + } + } + + return err(this.error) as Result< + T | InferOkTypes>, + E | InferErrTypes> + > + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars asyncAndThen(_f: (t: T) => ResultAsync): ResultAsync { return errAsync(this.error) diff --git a/tests/index.test.ts b/tests/index.test.ts index 9f089a9d..304d289c 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -13,6 +13,7 @@ import { okAsync, Result, ResultAsync, + TaggedError, } from '../src' import { vitest, describe, expect, it } from 'vitest' @@ -475,6 +476,71 @@ describe('Result.Err', () => { }) }) +describe('TaggedError', () => { + class ParseError extends TaggedError<'ParseError'> { + constructor(public readonly input: string) { + super('ParseError', `Unable to parse: ${input}`) + } + } + + class ValidationError extends TaggedError<'ValidationError'> { + constructor(public readonly reason: string) { + super('ValidationError', `Validation failed: ${reason}`) + } + } + + it('catches matching tagged errors on Result', () => { + const result = err(new ParseError('abc')).catchTags({ + ParseError: (_error) => ok(3), + }) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toBe(3) + }) + + it('passes through when no matching handler is present', () => { + const source = err(new ValidationError('too short')) + + const result = source.catchTags({ + ParseError: (_error) => ok(123), + }) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBe(source._unsafeUnwrapErr()) + }) + + it('does not catch non-tagged errors', () => { + const originalError = new Error('plain error') + const result = err(originalError).catchTags({ + ParseError: (_error: never) => ok(0), + }) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBe(originalError) + }) + + it('catches matching tagged errors on ResultAsync', async () => { + const result = await errAsync( + new ValidationError('missing field'), + ).catchTags({ + ValidationError: (_error) => okAsync('missing field'.length), + }) + + expect(result.isOk()).toBe(true) + expect(result._unsafeUnwrap()).toBe('missing field'.length) + }) + + it('passes through non-matching tags on ResultAsync', async () => { + const sourceError = new ValidationError('missing field') + const result = await errAsync(sourceError).catchTags({ + ParseError: (_error) => okAsync(1), + }) + + expect(result.isErr()).toBe(true) + expect(result._unsafeUnwrapErr()).toBe(sourceError) + }) +}) + describe('Result.fromThrowable', () => { it('Creates a function that returns an OK result when the inner function does not throw', () => { const hello = (): string => 'hello' diff --git a/tests/typecheck-tests.ts b/tests/typecheck-tests.ts index 55a3cd3d..ca560362 100644 --- a/tests/typecheck-tests.ts +++ b/tests/typecheck-tests.ts @@ -12,6 +12,7 @@ import { okAsync, Result, ResultAsync, + TaggedError, } from '../src' import { safeTry, Transpose } from '../src/result' import { type N, Test } from 'ts-toolbelt' @@ -393,6 +394,41 @@ type CreateTuple = }); }); + (function describe(_ = 'catchTags') { + class ParseError extends TaggedError<'ParseError'> { + constructor(readonly input: string) { + super('ParseError', input) + } + } + + class ValidationError extends TaggedError<'ValidationError'> { + constructor(readonly reason: string) { + super('ValidationError', reason) + } + } + + (function it(_ = 'suggests keys based on tagged errors in Result error union') { + type SourceError = ParseError | ValidationError | Error + type Expectation = Result + + const source: Result = err(new ParseError('raw-input')) + const result: Expectation = source.catchTags({ + ParseError: (_error) => ok('raw-input'), + }) + }); + + (function it(_ = 'supports multiple handlers and unions their output types') { + type SourceError = ParseError | ValidationError | Error + type Expectation = Result + + const source: Result = err(new ValidationError('missing-field')) + const result: Expectation = source.catchTags({ + ParseError: (_error) => ok(true), + ValidationError: (_error) => err(new Error('validated')), + }) + }); + }); + (function describe(_ = 'match') { (function it(_ = 'the type of the arguments match the types of the result') { type OKExpectation = number @@ -1693,6 +1729,41 @@ type CreateTuple = }); }); + (function describe(_ = 'catchTags') { + class ParseError extends TaggedError<'ParseError'> { + constructor(readonly input: string) { + super('ParseError', input) + } + } + + class ValidationError extends TaggedError<'ValidationError'> { + constructor(readonly reason: string) { + super('ValidationError', reason) + } + } + + (function it(_ = 'suggests keys based on tagged errors in ResultAsync error union') { + type SourceError = ParseError | ValidationError | Error + type Expectation = ResultAsync + + const source: ResultAsync = errAsync(new ParseError('raw-input')) + const result: Expectation = source.catchTags({ + ParseError: (_error) => okAsync('raw-input'), + }) + }); + + (function it(_ = 'supports mixing Result and ResultAsync handlers') { + type SourceError = ParseError | ValidationError | Error + type Expectation = ResultAsync + + const source: ResultAsync = errAsync(new ValidationError('missing-field')) + const result: Expectation = source.catchTags({ + ParseError: (_error) => ok(true), + ValidationError: (_error) => errAsync(new Error('validated')), + }) + }); + }); + (function describe(_ = 'combine') { (function it(_ = 'combines different result asyncs into one') { type Expectation = ResultAsync<[ number, string, boolean, boolean ], Error | string | string[]>;