Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
61 changes: 60 additions & 1 deletion src/result-async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
MembersToUnion,
} from './result'

import { Err, Ok, Result } from './'
import { Err, Ok, Result, TaggedError } from './'
import {
combineResultAsyncList,
combineResultAsyncListWithAllErrors,
Expand Down Expand Up @@ -199,6 +199,34 @@ export class ResultAsync<T, E> implements PromiseLike<Result<T, E>> {
)
}

catchTags<Handlers extends CatchTagsHandlersFor<E>>(
handlers: Handlers,
): ResultAsync<T | CatchTagsHandlerOkTypes<Handlers>, E | CatchTagsHandlerErrTypes<Handlers>>
// 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<T, E>) => {
if (res.isOk()) {
return new Ok(res.value)
}

if (res.error instanceof TaggedError) {
const handler = handlers[res.error._tag] as
| ((
error: TaggedErrorUnion,
) => Result<unknown, unknown> | ResultAsync<unknown, unknown>)
| undefined
if (handler !== undefined) {
const handledResult = handler(res.error)
return handledResult instanceof ResultAsync ? handledResult._promise : handledResult
}
}

return new Err(res.error)
}),
)
}

match<A, B = A>(ok: (t: T) => A, _err: (e: E) => B): Promise<A | B> {
return this._promise.then((res) => res.match(ok, _err))
}
Expand Down Expand Up @@ -336,3 +364,34 @@ type TraverseWithAllErrorsAsync<T, Depth extends number = 5> = TraverseAsync<

// Converts a reaodnly array into a writable array
type Writable<T> = T extends ReadonlyArray<unknown> ? [...T] : T

type TaggedErrorUnion = TaggedError<string>
type CatchTagsHandlers = {
[K in string]?: (
error: TaggedErrorUnion,
) => Result<unknown, unknown> | ResultAsync<unknown, unknown>
}
type CatchTagsHandlersFor<E> = Partial<
{
[K in Extract<Extract<E, TaggedErrorUnion>['_tag'], string>]: (
error: Extract<Extract<E, TaggedErrorUnion>, { _tag: K }>,
) => Result<unknown, unknown> | ResultAsync<unknown, unknown>
}
>
type CatchTagsHandlerResultUnion<Handlers> = {
[K in keyof Handlers]: Handlers[K] extends (...args: readonly unknown[]) => infer R ? R : never
}[keyof Handlers]
type CatchTagsHandlerResultSyncUnion<Handlers> = Extract<
CatchTagsHandlerResultUnion<Handlers>,
Result<unknown, unknown>
>
type CatchTagsHandlerResultAsyncUnion<Handlers> = Extract<
CatchTagsHandlerResultUnion<Handlers>,
ResultAsync<unknown, unknown>
>
type CatchTagsHandlerOkTypes<Handlers> =
| InferOkTypes<CatchTagsHandlerResultSyncUnion<Handlers>>
| InferAsyncOkTypes<CatchTagsHandlerResultAsyncUnion<Handlers>>
type CatchTagsHandlerErrTypes<Handlers> =
| InferErrTypes<CatchTagsHandlerResultSyncUnion<Handlers>>
| InferAsyncErrTypes<CatchTagsHandlerResultAsyncUnion<Handlers>>
77 changes: 75 additions & 2 deletions src/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,31 @@ export namespace Result {

export type Result<T, E> = Ok<T, E> | Err<T, E>

type TaggedErrorUnion = TaggedError<string>
type CatchTagsHandlers = {
[K in string]?: (error: TaggedErrorUnion) => Result<unknown, unknown>
}
type CatchTagsHandlersFor<E> = Partial<
{
[K in Extract<Extract<E, TaggedErrorUnion>['_tag'], string>]: (
error: Extract<Extract<E, TaggedErrorUnion>, { _tag: K }>,
) => Result<unknown, unknown>
}
>
type CatchTagsHandlerResultUnion<Handlers> = {
[K in keyof Handlers]: Handlers[K] extends (...args: readonly unknown[]) => infer R ? R : never
}[keyof Handlers]

export class TaggedError<Tag extends string = string> extends Error {
readonly _tag: Tag

constructor(tag: Tag, message?: string) {
super(message)
this._tag = tag
this.name = this.constructor.name
}
}

export function ok<T, E = never>(value: T): Ok<T, E>
export function ok<T extends void = void, E = never>(value: void): Ok<void, E>
export function ok<T, E = never>(value: T): Ok<T, E> {
Expand Down Expand Up @@ -232,6 +257,16 @@ interface IResult<T, E> {
): Result<InferOkTypes<R> | T, InferErrTypes<R>>
orElse<U, A>(f: (e: E) => Result<U, A>): Result<U | T, A>

/**
* Catches tagged errors by matching against the `_tag` property.
*/
catchTags<Handlers extends CatchTagsHandlersFor<E>>(
handlers: Handlers,
): Result<
T | InferOkTypes<CatchTagsHandlerResultUnion<Handlers>>,
E | InferErrTypes<CatchTagsHandlerResultUnion<Handlers>>
>

/**
* Similar to `map` Except you must return a new `Result`.
*
Expand Down Expand Up @@ -367,6 +402,15 @@ export class Ok<T, E> implements IResult<T, E> {
return ok(this.value)
}

catchTags<Handlers extends CatchTagsHandlersFor<E>>(
_handlers: Handlers,
): Result<
T | InferOkTypes<CatchTagsHandlerResultUnion<Handlers>>,
E | InferErrTypes<CatchTagsHandlerResultUnion<Handlers>>
> {
return ok(this.value)
}

asyncAndThen<U, F>(f: (t: T) => ResultAsync<U, F>): ResultAsync<U, E | F> {
return f(this.value)
}
Expand Down Expand Up @@ -396,8 +440,10 @@ export class Ok<T, E> implements IResult<T, E> {

safeUnwrap(): Generator<Err<never, E>, T> {
const value = this.value
/* eslint-disable-next-line require-yield */
return (function* () {
for (const _err of [] as Err<never, E>[]) {
yield _err
}
return value
})()
}
Expand All @@ -410,8 +456,10 @@ export class Ok<T, E> implements IResult<T, E> {
throw createNeverThrowError('Called `_unsafeUnwrapErr` on an Ok', this, config)
}

// eslint-disable-next-line @typescript-eslint/no-this-alias, require-yield
*[Symbol.iterator](): Generator<Err<never, E>, T> {
for (const _err of [] as Err<never, E>[]) {
yield _err
}
return this.value
}
}
Expand Down Expand Up @@ -471,6 +519,31 @@ export class Err<T, E> implements IResult<T, E> {
return f(this.error)
}

catchTags<Handlers extends CatchTagsHandlersFor<E>>(
handlers: Handlers,
): Result<
T | InferOkTypes<CatchTagsHandlerResultUnion<Handlers>>,
E | InferErrTypes<CatchTagsHandlerResultUnion<Handlers>>
> {
if (this.error instanceof TaggedError) {
const keyedHandlers = handlers as CatchTagsHandlers
const handler = keyedHandlers[this.error._tag] as
| ((error: TaggedErrorUnion) => Result<unknown, unknown>)
| undefined
if (handler !== undefined) {
return handler(this.error as TaggedErrorUnion) as Result<
T | InferOkTypes<CatchTagsHandlerResultUnion<Handlers>>,
E | InferErrTypes<CatchTagsHandlerResultUnion<Handlers>>
>
}
}

return err(this.error) as Result<
T | InferOkTypes<CatchTagsHandlerResultUnion<Handlers>>,
E | InferErrTypes<CatchTagsHandlerResultUnion<Handlers>>
>
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
asyncAndThen<U, F>(_f: (t: T) => ResultAsync<U, F>): ResultAsync<U, E | F> {
return errAsync<U, E>(this.error)
Expand Down
66 changes: 66 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
okAsync,
Result,
ResultAsync,
TaggedError,
} from '../src'

import { vitest, describe, expect, it } from 'vitest'
Expand Down Expand Up @@ -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<number, ParseError | ValidationError>(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<number, ParseError | ValidationError>(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<number, Error>(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<number, ParseError | ValidationError>(
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<number, ParseError | ValidationError>(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'
Expand Down
71 changes: 71 additions & 0 deletions tests/typecheck-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
okAsync,
Result,
ResultAsync,
TaggedError,
} from '../src'
import { safeTry, Transpose } from '../src/result'
import { type N, Test } from 'ts-toolbelt'
Expand Down Expand Up @@ -393,6 +394,41 @@ type CreateTuple<L, V = string> =
});
});

(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<number | string, SourceError | Error>

const source: Result<number, SourceError> = 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<number | boolean, SourceError | Error>

const source: Result<number, SourceError> = 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
Expand Down Expand Up @@ -1693,6 +1729,41 @@ type CreateTuple<L, V = string> =
});
});

(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<number | string, SourceError | Error>

const source: ResultAsync<number, SourceError> = 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<number | boolean, SourceError | Error>

const source: ResultAsync<number, SourceError> = 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[]>;
Expand Down