/* eslint-disable @typescript-eslint/prefer-promise-reject-errors */ /* eslint-disable @typescript-eslint/only-throw-error */ // TODO: fix, should only allow `Error` type import { z } from 'zod'; import { logger } from '../../test/util'; import { AsyncResult, Result } from './result'; describe('util/result', () => { describe('Result', () => { describe('constructors', () => { it('ok result', () => { const res = Result.ok(42); expect(res).toEqual({ res: { ok: true, val: 42, }, }); }); it('error result', () => { const res = Result.err('oops'); expect(res).toEqual({ res: { ok: false, err: 'oops', }, }); }); }); describe('Wrapping', () => { it('wraps callback returning value', () => { const res = Result.wrap(() => 42); expect(res).toEqual(Result.ok(42)); }); it('handles throw in callback', () => { const res = Result.wrap(() => { throw 'oops'; }); expect(res).toEqual(Result.err('oops')); }); it('wraps callback returning promise', () => { const res = Result.wrap(() => Promise.resolve(42)); expect(res).toEqual(AsyncResult.ok(42)); }); it('wraps callback returning failed promise', () => { const err = new Error('unknown'); const res = Result.wrap(() => Promise.reject(err)); expect(res).toEqual(AsyncResult.err(err)); }); it('wraps nullable callback', () => { const res: Result<number, 'oops'> = Result.wrapNullable( (): number | null => 42, 'oops', ); expect(res).toEqual(Result.ok(42)); }); it('wraps nullable callback null', () => { const res = Result.wrapNullable(() => null, 'oops'); expect(res).toEqual(Result.err('oops')); }); it('wraps nullable callback undefined', () => { const res = Result.wrapNullable(() => undefined, 'oops'); expect(res).toEqual(Result.err('oops')); }); it('distincts between null and undefined callback results', () => { expect(Result.wrapNullable(() => null, 'null', 'undefined')).toEqual( Result.err('null'), ); expect( Result.wrapNullable(() => undefined, 'null', 'undefined'), ).toEqual(Result.err('undefined')); }); it('handles nullable callback error', () => { const res = Result.wrapNullable(() => { throw 'oops'; }, 'nullable'); expect(res).toEqual(Result.err('oops')); }); it('wraps pure nullable value', () => { const res = Result.wrapNullable(42, 'oops'); expect(res).toEqual(Result.ok(42)); }); it('wraps nullable value null', () => { const res = Result.wrapNullable(null, 'oops'); expect(res).toEqual(Result.err('oops')); }); it('wraps nullable value undefined', () => { const res = Result.wrapNullable(undefined, 'oops'); expect(res).toEqual(Result.err('oops')); }); it('wraps zod parse result', () => { const schema = z.string().transform((x) => x.toUpperCase()); expect(Result.wrap(schema.safeParse('foo'))).toEqual(Result.ok('FOO')); expect(Result.wrap(schema.safeParse(42))).toMatchObject( Result.err({ issues: [ { code: 'invalid_type', expected: 'string', received: 'number' }, ], }), ); }); }); describe('Unwrapping', () => { it('unwraps successful value', () => { const res = Result.ok(42); expect(res.unwrap()).toEqual({ ok: true, val: 42, }); }); it('unwraps error value', () => { const res = Result.err('oops'); expect(res.unwrap()).toEqual({ ok: false, err: 'oops', }); }); it('skips fallback for successful value', () => { const res: Result<number> = Result.ok(42); expect(res.unwrapOr(-1)).toBe(42); }); it('uses fallback for error value', () => { const res: Result<number, string> = Result.err('oops'); expect(res.unwrapOr(42)).toBe(42); }); it('unwrapOr throws uncaught transform error', () => { const res = Result.ok(42); expect(() => res .transform(() => { throw 'oops'; }) .unwrapOr(0), ).toThrow('oops'); }); it('unwrap throws uncaught transform error', () => { const res = Result.ok(42); expect(() => res .transform(() => { throw 'oops'; }) .unwrap(), ).toThrow('oops'); }); it('returns ok-value for unwrapOrThrow', () => { const res = Result.ok(42); expect(res.unwrapOrThrow()).toBe(42); }); it('throws error for unwrapOrThrow on error result', () => { const res = Result.err('oops'); expect(() => res.unwrapOrThrow()).toThrow('oops'); }); it('unwrapOrNull returns value for ok-result', () => { const res = Result.ok(42); expect(res.unwrapOrNull()).toBe(42); }); it('unwrapOrNull returns null for error result', () => { const res = Result.err('oops'); expect(res.unwrapOrNull()).toBeNull(); }); it('unwrapOrNull throws uncaught transform error', () => { const res = Result.ok(42); expect(() => res .transform(() => { throw 'oops'; }) .unwrapOrNull(), ).toThrow('oops'); }); }); describe('Transforming', () => { it('transforms value to value', () => { const res = Result.ok('foo').transform((x) => x.toUpperCase()); expect(res).toEqual(Result.ok('FOO')); }); it('transforms value to Result', () => { const res = Result.ok('foo').transform((x) => Result.ok(x.toUpperCase()), ); expect(res).toEqual(Result.ok('FOO')); }); it('skips transform for error Result', () => { const res: Result<number, string> = Result.err('oops'); const fn = jest.fn((x: number) => x + 1); expect(res.transform(fn)).toEqual(Result.err('oops')); expect(fn).not.toHaveBeenCalled(); }); it('logs and returns error on transform failure', () => { const res = Result.ok('foo').transform(() => { throw 'oops'; }); expect(res).toEqual(Result._uncaught('oops')); expect(logger.logger.warn).toHaveBeenCalledWith( { err: 'oops' }, 'Result: unhandled transform error', ); }); it('automatically converts zod values', () => { const schema = z.string().transform((x) => x.toUpperCase()); const res = Result.ok('foo').transform((x) => schema.safeParse(x)); expect(res).toEqual(Result.ok('FOO')); }); }); describe('Catch', () => { it('bypasses ok result', () => { const res = Result.ok(42); expect(res.catch(() => Result.ok(0))).toEqual(Result.ok(42)); expect(res.catch(() => Result.ok(0))).toBe(res); }); it('bypasses uncaught transform errors', () => { const res = Result.ok(42).transform(() => { throw 'oops'; }); expect(res.catch(() => Result.ok(0))).toEqual(Result._uncaught('oops')); expect(res.catch(() => Result.ok(0))).toBe(res); }); it('converts error to Result', () => { const error: Result<number, string> = Result.err<string>('oops'); const result = error.catch((_err) => Result.ok<number>(42)); expect(result).toEqual(Result.ok(42)); }); it('handles error thrown in catch function', () => { const result = Result.err<string>('oops').catch(() => { throw 'oops'; }); expect(result).toEqual(Result._uncaught('oops')); }); }); describe('Parsing', () => { it('parses Zod schema', () => { const schema = z .string() .transform((x) => x.toUpperCase()) .nullish(); expect(Result.parse('foo', schema)).toEqual(Result.ok('FOO')); expect(Result.parse(42, schema).unwrap()).toMatchObject({ err: { issues: [{ message: 'Expected string, received number' }] }, }); expect(Result.parse(undefined, schema).unwrap()).toMatchObject({ err: { issues: [ { message: `Result can't accept nullish values, but input was parsed by Zod schema to undefined`, }, ], }, }); expect(Result.parse(null, schema).unwrap()).toMatchObject({ err: { issues: [ { message: `Result can't accept nullish values, but input was parsed by Zod schema to null`, }, ], }, }); }); it('parses Zod schema by piping from Result', () => { const schema = z .string() .transform((x) => x.toUpperCase()) .nullish(); expect(Result.ok('foo').parse(schema)).toEqual(Result.ok('FOO')); expect(Result.ok(42).parse(schema).unwrap()).toMatchObject({ err: { issues: [{ message: 'Expected string, received number' }] }, }); expect(Result.err('oops').parse(schema)).toEqual(Result.err('oops')); }); }); describe('Handlers', () => { it('supports value handlers', () => { const cb = jest.fn(); Result.ok(42).onValue(cb); expect(cb).toHaveBeenCalledWith(42); }); it('supports error handlers', () => { const cb = jest.fn(); Result.err('oops').onError(cb); expect(cb).toHaveBeenCalledWith('oops'); }); it('handles error thrown in value handler', () => { const res = Result.ok(42).onValue(() => { throw 'oops'; }); expect(res).toEqual(Result._uncaught('oops')); }); it('handles error thrown in error handler', () => { const res = Result.err('oops').onError(() => { throw 'oops'; }); expect(res).toEqual(Result._uncaught('oops')); }); }); }); describe('AsyncResult', () => { describe('Wrapping', () => { it('wraps promise', async () => { const res: AsyncResult<number, string> = Result.wrap( Promise.resolve(42), ); await expect(res).resolves.toEqual(Result.ok(42)); }); it('wraps Result promise', async () => { const res: AsyncResult<number, string> = Result.wrap( Promise.resolve(Result.ok(42)), ); await expect(res).resolves.toEqual(Result.ok(42)); }); it('handles rejected promise', async () => { const res: AsyncResult<number, string> = Result.wrap( Promise.reject('oops'), ); await expect(res).resolves.toEqual(Result.err('oops')); }); it('wraps nullable promise', async () => { const res: AsyncResult<number, 'oops'> = Result.wrapNullable( Promise.resolve<number | null>(42), 'oops', ); await expect(res).resolves.toEqual(Result.ok(42)); }); it('wraps promise returning null', async () => { const res = Result.wrapNullable(Promise.resolve(null), 'oops'); await expect(res).resolves.toEqual(Result.err('oops')); }); it('wraps promise returning undefined', async () => { const res = Result.wrapNullable(Promise.resolve(undefined), 'oops'); await expect(res).resolves.toEqual(Result.err('oops')); }); it('distincts between null and undefined promise results', async () => { await expect( Result.wrapNullable(Promise.resolve(null), 'null', 'undefined'), ).resolves.toEqual(Result.err('null')); await expect( Result.wrapNullable(Promise.resolve(undefined), 'null', 'undefined'), ).resolves.toEqual(Result.err('undefined')); }); it('handles rejected nullable promise', async () => { const res = Result.wrapNullable(Promise.reject('oops'), 'nullable'); await expect(res).resolves.toEqual(Result.err('oops')); }); }); describe('Unwrapping', () => { it('unwraps successful AsyncResult', async () => { const res = Result.wrap(Promise.resolve(42)); await expect(res.unwrap()).resolves.toEqual({ ok: true, val: 42, }); }); it('unwraps error AsyncResult', async () => { const res = Result.wrap(Promise.reject('oops')); await expect(res.unwrap()).resolves.toEqual({ ok: false, err: 'oops', }); }); it('skips fallback for successful AsyncResult', async () => { const res = Result.wrap(Promise.resolve(42)); await expect(res.unwrapOr(0)).resolves.toBe(42); }); it('uses fallback for error AsyncResult', async () => { const res = Result.wrap(Promise.reject('oops')); await expect(res.unwrapOr(42)).resolves.toBe(42); }); it('returns ok-value for unwrapOrThrow', async () => { const res = Result.wrap(Promise.resolve(42)); await expect(res.unwrapOrThrow()).resolves.toBe(42); }); it('rejects for error for unwrapOrThrow', async () => { const res = Result.wrap(Promise.reject('oops')); await expect(res.unwrapOrThrow()).rejects.toBe('oops'); }); it('unwrapOrNull returns value for ok-result', async () => { const res = AsyncResult.ok(42); await expect(res.unwrapOrNull()).resolves.toBe(42); }); it('unwrapOrNull returns null for error result', async () => { const res = AsyncResult.err('oops'); await expect(res.unwrapOrNull()).resolves.toBeNull(); }); }); describe('Transforming', () => { it('transforms AsyncResult to pure value', async () => { const res = await AsyncResult.ok('foo').transform((x) => x.toUpperCase(), ); expect(res).toEqual(Result.ok('FOO')); }); it('transforms AsyncResult to Result', async () => { const res = await AsyncResult.ok('foo').transform((x) => Result.ok(x.toUpperCase()), ); expect(res).toEqual(Result.ok('FOO')); }); it('transforms Result to AsyncResult', async () => { const res = await Result.ok('foo').transform((x) => AsyncResult.ok(x.toUpperCase()), ); expect(res).toEqual(Result.ok('FOO')); }); it('transforms AsyncResult to AsyncResult', async () => { const res = await AsyncResult.ok('foo').transform((x) => AsyncResult.ok(x.toUpperCase()), ); expect(res).toEqual(Result.ok('FOO')); }); it('skips transform for failed promises', async () => { const res = AsyncResult.err('oops'); const fn = jest.fn((x: number) => x + 1); await expect(res.transform(fn)).resolves.toEqual(Result.err('oops')); expect(fn).not.toHaveBeenCalled(); }); it('asyncronously transforms successfull promise to value', async () => { const res = await AsyncResult.ok('foo').transform((x) => Promise.resolve(x.toUpperCase()), ); expect(res).toEqual(Result.ok('FOO')); }); it('asynchronously transforms successful AsyncResult to Result', async () => { const res = await AsyncResult.ok('foo').transform((x) => Promise.resolve(Result.ok(x.toUpperCase())), ); expect(res).toEqual(Result.ok('FOO')); }); it('asynchronously transforms value to value', async () => { const res = await Result.ok('foo').transform((x) => Promise.resolve(x.toUpperCase()), ); expect(res).toEqual(Result.ok('FOO')); }); it('asynchronously transforms value to Result', async () => { const res = await Result.ok('foo').transform((x) => Promise.resolve(Result.ok(x.toUpperCase())), ); expect(res).toEqual(Result.ok('FOO')); }); it('skips async transform for error Result', async () => { const input: Result<number, string> = Result.err('oops'); const fn = jest.fn((x: number) => Promise.resolve(x + 1)); const res = await input.transform(fn); expect(res).toEqual(Result.err('oops')); expect(fn).not.toHaveBeenCalled(); }); it('skips async transform for rejected promise', async () => { const res: AsyncResult<number, string> = AsyncResult.err('oops'); const fn = jest.fn((x: number) => Promise.resolve(x + 1)); await expect(res.transform(fn)).resolves.toEqual(Result.err('oops')); expect(fn).not.toHaveBeenCalled(); }); it('re-wraps error thrown via unwrapping in async transform', async () => { const res = await AsyncResult.ok(42) .transform(async (): Promise<number> => { await Promise.resolve(); throw 'oops'; }) .transform((x) => x + 1); expect(res).toEqual(Result._uncaught('oops')); }); it('handles error thrown on Result async transform', async () => { const res = Result.ok('foo'); await expect( res.transform((_) => Promise.reject('oops')), ).resolves.toEqual(Result._uncaught('oops')); expect(logger.logger.warn).toHaveBeenCalledWith( { err: 'oops' }, 'Result: unhandled async transform error', ); }); it('handles error thrown on promise transform', async () => { const res = AsyncResult.ok('foo'); await expect( res.transform(() => { throw 'bar'; }), ).resolves.toEqual(Result._uncaught('bar')); expect(logger.logger.warn).toHaveBeenCalledWith( { err: 'bar' }, 'AsyncResult: unhandled transform error', ); }); it('handles error thrown on promise async transform', async () => { const res = AsyncResult.ok('foo'); await expect( res.transform(() => Promise.reject('bar')), ).resolves.toEqual(Result._uncaught('bar')); expect(logger.logger.warn).toHaveBeenCalledWith( { err: 'bar' }, 'AsyncResult: unhandled async transform error', ); }); it('accumulates error types into union type during chained transform', async () => { const fn1 = (x: string): Result<string, string> => Result.ok(x.toUpperCase()); const fn2 = (x: string): Result<string[], number> => Result.ok(x.split('')); const fn3 = (x: string[]): Result<string, boolean> => Result.ok(x.join('-')); type Res = Result<string, string | number | boolean>; const res: Res = await AsyncResult.ok('foo') .transform(fn1) .transform(fn2) .transform(fn3); expect(res).toEqual(Result.ok('F-O-O')); }); it('asynchronously transforms Result to zod values', async () => { const schema = z.string().transform((x) => x.toUpperCase()); const res = await Result.ok('foo').transform((x) => Promise.resolve(schema.safeParse(x)), ); expect(res).toEqual(Result.ok('FOO')); }); it('transforms AsyncResult to zod values', async () => { const schema = z.string().transform((x) => x.toUpperCase()); const res = await AsyncResult.ok('foo').transform((x) => schema.safeParse(x), ); expect(res).toEqual(Result.ok('FOO')); }); }); describe('Catch', () => { it('converts error to AsyncResult', async () => { const error: Result<number, string> = Result.err<string>('oops'); const result = await error.catch(() => AsyncResult.ok(42)); expect(result).toEqual(Result.ok(42)); }); it('converts error to Promise', async () => { const fallback = Promise.resolve(Result.ok(42)); const error: Result<number, string> = Result.err<string>('oops'); const result = await error.catch(() => fallback); expect(result).toEqual(Result.ok(42)); }); it('handles error thrown in Promise result', async () => { const fallback = Promise.reject('oops'); const result = await Result.err<string>('oops').catch(() => fallback); expect(result).toEqual(Result._uncaught('oops')); }); it('converts AsyncResult error to Result', async () => { const error: AsyncResult<number, string> = AsyncResult.err<string>('oops'); const result = await error.catch(() => AsyncResult.ok<number>(42)); expect(result).toEqual(Result.ok(42)); }); }); }); describe('Parsing', () => { it('parses Zod schema by piping from AsyncResult', async () => { const schema = z .string() .transform((x) => x.toUpperCase()) .nullish(); expect(await AsyncResult.ok('foo').parse(schema)).toEqual( Result.ok('FOO'), ); expect(await AsyncResult.ok(42).parse(schema).unwrap()).toMatchObject({ err: { issues: [{ message: 'Expected string, received number' }] }, }); }); it('handles uncaught error thrown in the steps before parsing', async () => { const res = await AsyncResult.ok(42) .transform(async (): Promise<number> => { await Promise.resolve(); throw 'oops'; }) .parse(z.number().transform((x) => x + 1)); expect(res).toEqual(Result._uncaught('oops')); }); }); describe('Handlers', () => { it('supports value handlers', async () => { const cb = jest.fn(); await AsyncResult.ok(42).onValue(cb); expect(cb).toHaveBeenCalledWith(42); }); it('supports error handlers', async () => { const cb = jest.fn(); await AsyncResult.err('oops').onError(cb); expect(cb).toHaveBeenCalledWith('oops'); }); it('handles error thrown in value handler', async () => { const res = await AsyncResult.ok(42).onValue(() => { throw 'oops'; }); expect(res).toEqual(Result._uncaught('oops')); }); it('handles error thrown in error handler', async () => { const res = await AsyncResult.err('oops').onError(() => { throw 'oops'; }); expect(res).toEqual(Result._uncaught('oops')); }); }); });