import { isBoolean, isUndefined } from '@sindresorhus/is'; import type { ZodError, output as ZodOutput, ZodType } from 'zod/v4'; import { NEVER } from 'zod/v4'; import { logger } from '../logger/index.ts'; import type { Nullish } from '../types/index.ts'; type Val = NonNullable; // ZodSafeParseResult is not exported from zod/v4 public API, derive from safeParse return type type ZodSafeParseResult = ReturnType['safeParse']>; interface Ok { readonly ok: true; readonly val: T; readonly err?: never; } interface Err { readonly ok: false; readonly err: E; readonly val?: never; /** * Internal flag to indicate that the error was thrown during `.transform()` * and will be re-thrown on `.unwrap()`. */ readonly _uncaught?: true; } type Res = Ok | Err; function isZodResult( input: unknown, ): input is ZodSafeParseResult { if ( // oxlint-disable-next-line renovate/prefer-is-object -- guards arbitrary callback results; isObject() matches functions, which must never be classified as zod results typeof input !== 'object' || input === null || Object.keys(input).length !== 2 || !('success' in input) || !isBoolean(input.success) ) { return false; } if (input.success) { return 'data' in input && !isUndefined(input.data) && input.data !== null; } return 'error' in input; } function fromZodResult( input: ZodSafeParseResult, ): Result { return input.success ? Result.ok(input.data) : Result.err(input.error); } /** * All non-nullable values that also are not Promises nor Zod results. * It's useful for restricting Zod results to not return `null` or `undefined`. */ type RawValue = Exclude< T, ZodSafeParseResult | Promise >; function fromNullable< T extends Val, ErrForNull extends Val, ErrForUndefined extends Val, >( input: Nullish, errForNull: ErrForNull, errForUndefined: ErrForUndefined, ): Result { if (input === null) { return Result.err(errForNull); } if (input === undefined) { return Result.err(errForUndefined); } return Result.ok(input); } /** * Class for representing a result that can fail. * * The mental model: * - `.wrap()` and `.wrapNullable()` are sinks * - `.transform()` are pipes which can be chained * - `.unwrap()` is the point of consumption */ export class Result { private readonly res: Res; private constructor(res: Res) { this.res = res; } static ok(val: T): Result { return new Result({ ok: true, val }); } static err(err: E): Result { return new Result({ ok: false, err }); } static _uncaught(err: E): Result { return new Result({ ok: false, err, _uncaught: true }); } /** * Wrap a callback or promise in a Result in such a way that any thrown errors * are caught and wrapped with `Result.err()` (and hence never re-thrown). * * In case of a promise, the `AsyncResult` is returned. * Use `.unwrap()` to get the `Promise>` from `AsyncResult`. * * ```ts * * // SYNC * const parse = (json: string) => Result.wrap(() => JSON.parse(json)); * * const { val, err } = parse('{"foo": "bar"}').unwrap(); * expect(val).toEqual({ foo: 'bar' }); * expect(err).toBeUndefined(); * * const { val, err } = parse('!!!').unwrap(); * expect(val).toBeUndefined(); * expect(err).toBeInstanceOf(SyntaxError); * * // ASYNC * const request = (url: string) => Result.wrap(http.get(url)); * * const { val, err } = await request('https://example.com').unwrap(); * expect(val).toBeString(); * expect(err).toBeUndefined(); * * ``` */ static wrap( zodResult: ZodSafeParseResult, ): Result; static wrap( callback: () => RawValue, ): Result; static wrap( callback: () => Promise>, ): AsyncResult; static wrap( promise: Promise>, ): AsyncResult; static wrap( promise: Promise>, ): AsyncResult; static wrap( input: | ZodSafeParseResult | (() => RawValue) | (() => Promise>) | Promise> | Promise>, ): Result | Result | AsyncResult { if (isZodResult(input)) { return fromZodResult(input); } if (input instanceof Promise) { return AsyncResult.wrap(input); } try { const result = input(); if (result instanceof Promise) { return AsyncResult.wrap(result); } return Result.ok(result); } catch (error) { return Result.err(error); } } /** * Similar to `Result.wrap()`, but helps to undo the billion dollar mistake by * replacing `null` or `undefined` with an error of provided type. * * Errors thrown inside the callback or promise are caught and wrapped with `Result.err()`, * hence never re-thrown. * * Since functions and promises returning nullable can't be wrapped with `Result.wrap()` * because `val` is constrained by being `NonNullable`, `null` and `undefined` * must be converted to some sort of `err` value. * * This method does exactly this, i.g. it is the feature-rich shorthand for: * * ```ts * const { val, err } = Result.wrap(() => { * const result = callback(); * return result === null || result === undefined * ? Result.err('oops') * : Result.ok(result); * }).unwrap(); * ``` * * In case of a promise, the `AsyncResult` is returned. * * ```ts * * // SYNC * const getHostname = (url: string) => * Result.wrapNullable( * () => parseUrl(url)?.hostname, * 'invalid-url' as const * ); * const { val, err } = getHostname('foobar').unwrap(); * expect(val).toBeUndefined(); * expect(err).toBe('invalid-url'); * * // ASYNC * const { val, err } = await Result.wrapNullable( * readLocalFile('yarn.lock'), * 'file-read-error' as const * ).unwrap(); * * ``` */ static wrapNullable< T extends Val, E extends Val = Error, ErrForNullable extends Val = Error, >( callback: () => Nullish, errForNullable: ErrForNullable, ): Result; static wrapNullable< T extends Val, E extends Val = Error, ErrForNull extends Val = Error, ErrForUndefined extends Val = Error, >( callback: () => Nullish, errForNull: ErrForNull, errForUndefined: ErrForUndefined, ): Result; static wrapNullable< T extends Val, E extends Val = Error, ErrForNullable extends Val = Error, >( promise: Promise>, errForNullable: ErrForNullable, ): AsyncResult; static wrapNullable< T extends Val, E extends Val = Error, ErrForNull extends Val = Error, ErrForUndefined extends Val = Error, >( promise: Promise>, errForNull: ErrForNull, errForUndefined: ErrForUndefined, ): AsyncResult; static wrapNullable< T extends Val, E extends Val = Error, ErrForNullable extends Val = Error, >( value: Nullish, errForNullable: ErrForNullable, ): Result; static wrapNullable< T extends Val, E extends Val = Error, ErrForNull extends Val = Error, ErrForUndefined extends Val = Error, >( value: Nullish, errForNull: ErrForNull, errForUndefined: ErrForUndefined, ): Result; static wrapNullable< T extends Val, E extends Val = Error, ErrForNull extends Val = Error, ErrForUndefined extends Val = Error, >( input: (() => Nullish) | Promise> | Nullish, arg2: ErrForNull, arg3?: ErrForUndefined, ): | Result | AsyncResult { const errForNull = arg2; const errForUndefined = arg3 ?? arg2; if (input instanceof Promise) { return AsyncResult.wrapNullable(input, errForNull, errForUndefined); } if (input instanceof Function) { try { const result = input(); return fromNullable(result, errForNull, errForUndefined); } catch (error) { return Result.err(error); } } return fromNullable(input, errForNull, errForUndefined); } /** * Returns a discriminated union for type-safe consumption of the result. * When error was uncaught during transformation, it's being re-thrown here. * * ```ts * * const { val, err } = Result.ok('foo').unwrap(); * expect(val).toBe('foo'); * expect(err).toBeUndefined(); * * ``` */ unwrap(): Res { if (this.res.ok) { return this.res; } if (this.res._uncaught) { // TODO: fix, should only allow `Error` type // oxlint-disable-next-line typescript/only-throw-error throw this.res.err; } return this.res; } /** * Returns a success value or a fallback value. * When error was uncaught during transformation, it's being re-thrown here. * * ```ts * * const value = Result.err('bar').unwrapOr('foo'); * expect(val).toBe('foo'); * * ``` */ unwrapOr(fallback: T): T { if (this.res.ok) { return this.res.val; } if (this.res._uncaught) { // TODO: fix, should only allow `Error` type // oxlint-disable-next-line typescript/only-throw-error throw this.res.err; } return fallback; } /** * Returns the ok-value or throw the error. */ unwrapOrThrow(): T { if (this.res.ok) { return this.res.val; } // TODO: fix, should only allow `Error` type // oxlint-disable-next-line typescript/only-throw-error throw this.res.err; } /** * Returns the ok-value or `null`. * When error was uncaught during transformation, it's being re-thrown here. */ unwrapOrNull(): T | null { if (this.res.ok) { return this.res.val; } if (this.res._uncaught) { // TODO: fix, should only allow `Error` type // oxlint-disable-next-line typescript/only-throw-error throw this.res.err; } return null; } /** * Transforms the ok-value, sync or async way. * * Transform functions SHOULD NOT throw. * Uncaught errors are logged and wrapped to `Result._uncaught()`, * which leads to re-throwing them in `unwrap()`. * * Zod `.safeParse()` results are converted automatically. * * ```ts * * // SYNC * const { val, err } = Result.ok('foo') * .transform((x) => x.length) * .unwrap(); * expect(val).toBe(3); * * // ASYNC * const { val, err } = await Result.wrap( * http.getJson('https://api.example.com/data.json') * ) * .transform(({ body }) => body) * .unwrap(); * * ``` */ transform( fn: (value: T) => Result, ): Result; transform( fn: (value: T) => AsyncResult, ): AsyncResult; transform( fn: (value: T) => ZodSafeParseResult>, ): Result; transform( fn: (value: T) => Promise>>, ): AsyncResult; transform( fn: (value: T) => Promise>, ): AsyncResult; transform( fn: (value: T) => Promise>, ): AsyncResult; transform(fn: (value: T) => RawValue): Result; transform( fn: ( value: T, ) => | Result | AsyncResult | ZodSafeParseResult> | Promise>> | Promise> | Promise> | RawValue, ): Result | AsyncResult { if (!this.res.ok) { return Result.err(this.res.err); } try { const result = fn(this.res.val); if (result instanceof Result) { return result; } if (result instanceof AsyncResult) { return result; } if (isZodResult(result)) { return fromZodResult(result); } if (result instanceof Promise) { return AsyncResult.wrap(result, (err) => { logger.warn({ err }, 'Result: unhandled async transform error'); return Result._uncaught(err); }); } return Result.ok(result); } catch (err) { logger.warn({ err }, 'Result: unhandled transform error'); return Result._uncaught(err); } } catch( fn: (err: E) => Result, ): Result; catch( fn: (err: E) => AsyncResult, ): AsyncResult; catch( fn: (err: E) => Promise>, ): AsyncResult; catch( fn: (err: E) => Result | AsyncResult | Promise>, ): Result | AsyncResult { if (this.res.ok) { return this as never; } if (this.res._uncaught) { return this as never; } try { const result = fn(this.res.err); if (result instanceof Promise) { return AsyncResult.wrap(result, (err) => { logger.warn( { err }, 'Result: unexpected error in async catch handler', ); return Result._uncaught(err); }); } return result; } catch (err) { logger.warn({ err }, 'Result: unexpected error in catch handler'); return Result._uncaught(err); } } /** * Given a `schema` and `input`, returns a `Result` with `val` being the parsed value. * Additionally, `null` and `undefined` values are converted into Zod error. */ static parse>( input: unknown, schema: Schema, ): Result>, ZodError> { const parseResult = schema .transform((result, ctx): NonNullable> => { if (result === undefined) { ctx.addIssue({ code: 'custom', message: `Result can't accept nullish values, but input was parsed by Zod schema to undefined`, }); return NEVER; } if (result === null) { ctx.addIssue({ code: 'custom', message: `Result can't accept nullish values, but input was parsed by Zod schema to null`, }); return NEVER; } return result; }) .safeParse(input); return fromZodResult>>(parseResult); } /** * Given a `schema`, returns a `Result` with `val` being the parsed value. * Additionally, `null` and `undefined` values are converted into Zod error. */ parse>( schema: Schema, ): Result>, E | ZodError> { if (this.res.ok) { return Result.parse(this.res.val, schema); } const err = this.res.err; if (this.res._uncaught) { return Result._uncaught(err); } return Result.err(err); } /** * Call `fn` on the `val` if the result is ok. */ onValue(fn: (value: T) => void): Result { if (this.res.ok) { try { fn(this.res.val); } catch (err) { return Result._uncaught(err); } } return this; } /** * Call `fn` on the `err` if the result is err. */ onError(fn: (err: E) => void): Result { if (!this.res.ok) { try { fn(this.res.err); } catch (err) { return Result._uncaught(err); } } return this; } } /** * This class is being used when `Result` methods encounter async code. * It isn't meant to be used directly, but exported for usage in type annotations. * * All the methods resemble `Result` methods, but work asynchronously. */ export class AsyncResult implements PromiseLike< Result > { private asyncResult: Promise>; private constructor(asyncResult: Promise>) { this.asyncResult = asyncResult; } // oxlint-disable-next-line unicorn/no-thenable -- required to implement PromiseLike then>( onfulfilled?: ((value: Result) => TResult1 | PromiseLike) | null, ): PromiseLike { return this.asyncResult.then(onfulfilled); } static ok(val: T): AsyncResult { return new AsyncResult(Promise.resolve(Result.ok(val))); } static err(err: NonNullable): AsyncResult { return new AsyncResult(Promise.resolve(Result.err(err))); } static wrap( promise: | Promise> | Promise> | Promise>, onErr?: (err: NonNullable) => Result, ): AsyncResult { return new AsyncResult( promise .then((value) => { if (value instanceof Result) { return value; } if (isZodResult(value)) { return fromZodResult(value); } return Result.ok(value); }) .catch((err) => { if (onErr) { return onErr(err); } return Result.err(err); }), ); } static wrapNullable< T extends Val, E extends Val, ErrForNull extends Val, ErrForUndefined extends Val, >( promise: Promise>, errForNull: NonNullable, errForUndefined: NonNullable, ): AsyncResult { return new AsyncResult( promise .then((value) => fromNullable(value, errForNull, errForUndefined)) .catch((err) => Result.err(err)), ); } /** * Returns a discriminated union for type-safe consumption of the result. * * ```ts * * const { val, err } = await Result.wrap(readFile('foo.txt')).unwrap(); * expect(val).toBe('foo'); * expect(err).toBeUndefined(); * * ``` */ unwrap(): Promise> { return this.asyncResult.then>((res) => res.unwrap()); } /** * Returns a success value or a fallback value. * * ```ts * * const val = await Result.wrap(readFile('foo.txt')).unwrapOr('bar'); * expect(val).toBe('bar'); * expect(err).toBeUndefined(); * * ``` */ unwrapOr(fallback: T): Promise { return this.asyncResult.then((res) => res.unwrapOr(fallback)); } /** * Returns the ok-value or throw the error. */ async unwrapOrThrow(): Promise { const result = await this.asyncResult; return result.unwrapOrThrow(); } /** * Returns the ok-value or `null`. */ unwrapOrNull(): Promise { return this.asyncResult.then((res) => res.unwrapOrNull()); } /** * Transforms the ok-value, sync or async way. * * Transform functions SHOULD NOT throw. * Uncaught errors are logged and wrapped to `Result._uncaught()`, * which leads to re-throwing them in `unwrap()`. * * Zod `.safeParse()` results are converted automatically. * * ```ts * * const { val, err } = await Result.wrap( * http.getJson('https://api.example.com/data.json') * ) * .transform(({ body }) => body) * .unwrap(); * * ``` */ transform( fn: (value: T) => Result, ): AsyncResult; transform( fn: (value: T) => AsyncResult, ): AsyncResult; transform( fn: (value: T) => ZodSafeParseResult>, ): AsyncResult; transform( fn: (value: T) => Promise>>, ): AsyncResult; transform( fn: (value: T) => Promise>, ): AsyncResult; transform( fn: (value: T) => Promise>, ): AsyncResult; transform(fn: (value: T) => RawValue): AsyncResult; transform( fn: ( value: T, ) => | Result | AsyncResult | ZodSafeParseResult> | Promise>> | Promise> | Promise> | RawValue, ): AsyncResult { return new AsyncResult( this.asyncResult .then((oldResult) => { const { ok, val: value, err: error } = oldResult.unwrap(); if (!ok) { return Result.err(error); } try { const result = fn(value); if (result instanceof Result) { return result; } if (result instanceof AsyncResult) { return result; } if (isZodResult(result)) { return fromZodResult(result); } if (result instanceof Promise) { return AsyncResult.wrap(result, (err) => { logger.warn( { err }, 'AsyncResult: unhandled async transform error', ); return Result._uncaught(err); }); } return Result.ok(result); } catch (err) { logger.warn({ err }, 'AsyncResult: unhandled transform error'); return Result._uncaught(err); } }) .catch((err) => { // Happens when `.unwrap()` of `oldResult` throws return Result._uncaught(err); }), ); } catch( fn: (err: NonNullable) => Result, ): AsyncResult; catch( fn: (err: NonNullable) => AsyncResult, ): AsyncResult; catch( fn: (err: NonNullable) => Promise>, ): AsyncResult; catch( fn: ( err: NonNullable, ) => Result | AsyncResult | Promise>, ): AsyncResult { const caughtAsyncResult: Promise> = this.asyncResult.then( (result) => result.catch(fn as never), ); return AsyncResult.wrap(caughtAsyncResult); } /** * Given a `schema`, returns a `Result` with `val` being the parsed value. * Additionally, `null` and `undefined` values are converted into Zod error. */ parse>( schema: Schema, ): AsyncResult>, E | ZodError> { return new AsyncResult( this.asyncResult .then((oldResult) => oldResult.parse(schema)) .catch( /* istanbul ignore next: should never happen */ (err) => Result._uncaught(err), ), ); } onValue(fn: (value: T) => void): AsyncResult { return new AsyncResult( this.asyncResult .then((result) => result.onValue(fn)) .catch( /* istanbul ignore next: should never happen */ (err) => Result._uncaught(err), ), ); } onError(fn: (err: E) => void): AsyncResult { return new AsyncResult( this.asyncResult .then((result) => result.onError(fn)) .catch( /* istanbul ignore next: should never happen */ (err) => Result._uncaught(err), ), ); } }