diff --git a/.changeset/chatty-falcons-arrive.md b/.changeset/chatty-falcons-arrive.md new file mode 100644 index 0000000..aed360d --- /dev/null +++ b/.changeset/chatty-falcons-arrive.md @@ -0,0 +1,5 @@ +--- +'funkcia': minor +--- + +Create `Option` and `Result` types diff --git a/README.md b/README.md index e51b7af..82d0d83 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

- `Result` and `Option` types inspired by the best parts of Rust, OCaml, Nim, Scala, and Haskell + Result and Option types inspired by the best parts of Rust, OCaml, Nim, Scala, and Haskell
providing a type-safe way to build your applications with better DX

diff --git a/package.json b/package.json index a16e4a3..e24bf61 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,20 @@ "types": "./dist/index.d.ts", "module": "./dist/index.mjs" }, + "./exceptions": { + "default": "./dist/exceptions.mjs", + "import": "./dist/exceptions.mjs", + "require": "./dist/exceptions.js", + "types": "./dist/exceptions.d.ts", + "module": "./dist/exceptions.mjs" + }, + "./functions": { + "default": "./dist/functions.mjs", + "import": "./dist/functions.mjs", + "require": "./dist/functions.js", + "types": "./dist/functions.d.ts", + "module": "./dist/functions.mjs" + }, "./json": { "default": "./dist/json.mjs", "import": "./dist/json.mjs", @@ -41,6 +55,13 @@ "types": "./dist/option.d.ts", "module": "./dist/option.mjs" }, + "./predicate": { + "default": "./dist/predicate.mjs", + "import": "./dist/predicate.mjs", + "require": "./dist/predicate.js", + "types": "./dist/predicate.d.ts", + "module": "./dist/predicate.mjs" + }, "./result": { "default": "./dist/result.mjs", "import": "./dist/result.mjs", @@ -71,7 +92,8 @@ ], "rules": { "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/member-ordering": "off" + "@typescript-eslint/member-ordering": "off", + "@typescript-eslint/strict-boolean-expressions": "off" } }, "scripts": { diff --git a/src/exceptions.ts b/src/exceptions.ts new file mode 100644 index 0000000..503c5d2 --- /dev/null +++ b/src/exceptions.ts @@ -0,0 +1,96 @@ +export abstract class TaggedError extends TypeError { + abstract readonly _tag: string; + + constructor(message?: string) { + super(message); + this.name = this.constructor.name; + } +} + +// ----------------------------- +// ---MARK: COMMON EXCEPTIONS--- +// ----------------------------- + +export class UnwrapError extends TaggedError { + readonly _tag = 'UnwrapError'; + + constructor(type: 'Option' | 'Result' | 'ResultError') { + switch (type) { + case 'Option': + super('called "Option.unwrap()" on a "None" value'); + break; + case 'Result': + super('called "Result.unwrap()" on an "Error" value'); + break; + case 'ResultError': + super('called "Result.unwrapError()" on an "Ok" value'); + break; + default: { + const _: never = type; + throw new Error(`invalid value passed to UnwrapError: "${_}"`); + } + } + } +} + +// ---MARK: OPTION EXCEPTIONS--- + +export class UnexpectedOptionException extends TaggedError { + readonly _tag = 'UnexpectedOption'; +} + +// ---MARK: RESULT EXCEPTIONS--- + +export class UnknownError extends TaggedError { + readonly _tag = 'UnknownError'; + + readonly thrownError: unknown; + + constructor(error: unknown) { + let message: string | undefined; + let stack: string | undefined; + let cause: unknown; + + if (error instanceof Error) { + message = error.message; + stack = error.stack; + cause = error.cause; + } else { + message = typeof error === 'string' ? error : JSON.stringify(error); + } + + super(message); + this.thrownError = error; + + if (stack != null) { + this.stack = stack; + } + + if (cause != null) { + this.cause = cause; + } + } +} + +export class MissingValueError extends TaggedError { + readonly _tag = 'MissingValue'; +} + +export class FailedPredicateError extends TaggedError { + readonly _tag = 'FailedPredicate'; + + constructor(readonly value: T) { + super('Predicate not fulfilled for Result value'); + } +} + +export class UnexpectedResultError extends TaggedError { + readonly _tag = 'UnexpectedResultError'; + + constructor( + override readonly cause: string, + readonly value: unknown, + ) { + super('Expected Result to be "Ok", but it was "Error"'); + } +} diff --git a/src/functions.ts b/src/functions.ts new file mode 100644 index 0000000..dd1c9de --- /dev/null +++ b/src/functions.ts @@ -0,0 +1,14 @@ +/** + * Returns the provided value. + * + * @example + * ```ts + * import { identity } from 'funkcia/functions'; + * + * // Output: 10 + * const result = identity(10); + * ``` + */ +export function identity(value: T): T { + return value; +} diff --git a/src/index.ts b/src/index.ts index 1311c88..427e050 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,3 @@ -import * as A from './array'; -import * as N from './number'; -import * as O from './option'; -import * as P from './predicate'; -import * as R from './result'; -import * as S from './string'; - -export * from './functions'; -export { - A, - N, - O, - P, - R, - S, - A as array, - N as number, - O as option, - P as predicate, - R as result, - S as string, -}; - -export type { AsyncOption, None, Option, Some } from './option'; -export type { AsyncResult, Err, Ok, Result } from './result'; +export { Option } from './option'; +export { Result } from './result'; +export * from './types'; diff --git a/src/internals/equality.ts b/src/internals/equality.ts new file mode 100644 index 0000000..f2c0a52 --- /dev/null +++ b/src/internals/equality.ts @@ -0,0 +1,3 @@ +export function refEquality(a: A, b: A): boolean { + return a === b; +} diff --git a/src/internals/inspect.ts b/src/internals/inspect.ts new file mode 100644 index 0000000..18b26e6 --- /dev/null +++ b/src/internals/inspect.ts @@ -0,0 +1 @@ +export const INSPECT_SYMBOL = Symbol.for('nodejs.util.inspect.custom'); diff --git a/src/internals/types.ts b/src/internals/types.ts new file mode 100644 index 0000000..7715d85 --- /dev/null +++ b/src/internals/types.ts @@ -0,0 +1,3 @@ +export type Falsy = false | '' | 0 | 0n | null | undefined; + +export type Task = () => Promise; diff --git a/src/json.ts b/src/json.ts new file mode 100644 index 0000000..22722bd --- /dev/null +++ b/src/json.ts @@ -0,0 +1,25 @@ +import { Result } from './result'; + +export class SafeJSON { + /** + * Converts a JavaScript Object Notation (JSON) string into an object. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse + */ + static parse = Result.produce< + Parameters, + unknown, + SyntaxError + >(JSON.parse, (e) => e as SyntaxError); + + /** + * Converts a JavaScript value to a JavaScript Object Notation (JSON) string. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify + */ + static stringify = Result.produce< + Parameters, + unknown, + TypeError + >(JSON.stringify, (e) => e as TypeError); +} diff --git a/src/option.spec.ts b/src/option.spec.ts index f05ea82..f5e19bf 100644 --- a/src/option.spec.ts +++ b/src/option.spec.ts @@ -1,376 +1,543 @@ -import { flow, pipe } from './functions'; -import * as N from './number'; -import * as O from './option'; -import * as R from './result'; -import * as S from './string'; +import { UnexpectedOptionException, UnwrapError } from './exceptions'; +import { type Falsy } from './internals/types'; +import { Option } from './option'; +import type { Nullable } from './types'; describe('Option', () => { - describe('conversions', () => { + describe('constructors', () => { + describe('some', () => { + it('creates a Some Option with the given value', () => { + const option = Option.some('hello world'); + + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(true); + expect(option.isNone()).toBe(false); + expect(option.unwrap()).toBe('hello world'); + }); + }); + + describe('none', () => { + it('creates a None Option', () => { + const option = Option.none(); + + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(false); + expect(option.isNone()).toBe(true); + expect(() => option.unwrap()).toThrow(UnwrapError); + }); + }); + describe('fromNullable', () => { - it('creates a Some when value is not nullable', () => { - expect(O.fromNullable('hello world')).toMatchOption( - O.some('hello world'), - ); + function nullable(value: Nullable): Nullable { + return value; + } + + it('creates a Some Option when the value is not nullable', () => { + const option = Option.fromNullable(nullable('hello world')); + + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(true); + expect(option.isNone()).toBe(false); + expect(option.unwrap()).toBe('hello world'); }); - const eachCase = it.each([undefined, null]); + it('creates a None Option when the value is nullable', () => { + const option = Option.fromNullable(nullable(null)); - eachCase('creates a None when value is %s', (nullable) => { - expect(O.fromNullable(nullable)).toBeNone(); + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(false); + expect(option.isNone()).toBe(true); + expect(() => option.unwrap()).toThrow(UnwrapError); }); }); describe('fromFalsy', () => { - it('creates a Some when value is not falsy', () => { - expect(O.fromFalsy('hello world')).toMatchOption(O.some('hello world')); - expect(O.fromFalsy(true)).toMatchOption(O.some(true)); - expect(O.fromFalsy(1)).toMatchOption(O.some(1)); + it('creates a Some Option when the value is not falsy', () => { + const value = 'hello world' as string | Falsy; + + const option = Option.fromFalsy(value); + + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(true); + expect(option.isNone()).toBe(false); + expect(option.unwrap()).toBe('hello world'); }); - const eachCase = it.each([null, undefined, 0, 0n, NaN, false, '']); + it('creates a None Option when the value is falsy', () => { + const testValues = [ + '', + 0, + 0n, + null, + undefined, + false, + ] as const satisfies Falsy[]; + + for (const value of testValues) { + const option = Option.fromFalsy(value); - eachCase('creates a None when value is %s', (falsy) => { - expect(O.fromFalsy(falsy)).toBeNone(); + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(false); + expect(option.isNone()).toBe(true); + expect(() => option.unwrap()).toThrow(UnwrapError); + } }); }); - describe('fromThrowable', () => { - it('creates a Some when the throwable function succeeds', () => { - expect( - O.fromThrowable(() => JSON.parse('{ "enabled": true }')), - ).toMatchOption(O.some({ enabled: true })); + describe('try', () => { + it('creates a Some Option when the function does not throw', () => { + const option = Option.try(() => 'hello world'); + + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(true); + expect(option.isNone()).toBe(false); + expect(option.unwrap()).toBe('hello world'); }); - it('creates a None when the throwable function fails', () => { - expect(O.fromThrowable(() => JSON.parse('{{ }}'))).toBeNone(); + it('creates a None Option when the function does not throw but returns null', () => { + const option = Option.try(() => null as string | null); + + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(false); + expect(option.isNone()).toBe(true); + expect(() => option.unwrap()).toThrow(UnwrapError); + }); + + it('creates a None Option when the function throws', () => { + const option = Option.try(() => { + throw new Error('calculation failed'); + }); + + expectTypeOf(option).toEqualTypeOf>(); + + expect(option.isSome()).toBe(false); + expect(option.isNone()).toBe(true); + expect(() => option.unwrap()).toThrow(UnwrapError); }); }); - describe('fromPredicate', () => { - interface Square { - kind: 'square'; - size: number; + describe('wrap', () => { + function hasEnabledSetting(enabled: boolean | null) { + switch (enabled) { + case true: + return Option.some('YES' as const); + case false: + return Option.some('NO' as const); + default: + return Option.none(); + } } - interface Circle { - kind: 'circle'; - radius: number; - } + it('returns a function with improved inference without changing behavior', () => { + const output = hasEnabledSetting(true); - type Shape = Square | Circle; + expectTypeOf(output).toEqualTypeOf< + Option<'YES'> | Option<'NO'> | Option + >(); - const shape = { kind: 'square', size: 2 } as Shape; + expect(output.isSome()).toBe(true); + expect(output.isNone()).toBe(false); + expect(output.unwrap()).toBe('YES'); - describe('data-first', () => { - it('creates a Some when the predicate is satisfied', () => { - expect( - O.fromPredicate( - shape, - (body): body is Square => body.kind === 'square', - ), - ).toMatchOption(O.some({ kind: 'square', size: 2 })); - }); + const wrapped = Option.wrap(hasEnabledSetting); - it('creates a None when the predicate is not satisfied', () => { - expect( - O.fromPredicate( - shape, - (body): body is Circle => body.kind === 'circle', - ), - ).toBeNone(); - }); + const result = wrapped(true); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + expect(result.unwrap()).toBe('YES'); }); + }); - describe('data-last', () => { - it('creates a Some when the predicate is satisfied', () => { - expect( - pipe( - shape, - O.fromPredicate((body): body is Square => body.kind === 'square'), - ), - ).toMatchOption(O.some({ kind: 'square', size: 2 })); - }); + describe('produce', () => { + it('wraps a function that might throw exceptions into a function that returns an Option', () => { + function divide(dividend: number, divisor: number): number { + if (divisor === 0) { + throw new Error('division by zero'); + } - it('creates a None when the predicate is not satisfied', () => { - expect( - pipe( - shape, - O.fromPredicate((body): body is Circle => body.kind === 'circle'), - ), - ).toBeNone(); - }); + return dividend / divisor; + } + + const safeDivide = Option.produce(divide); + + const someOption = safeDivide(10, 2); + expectTypeOf(someOption).toEqualTypeOf>(); + + expect(someOption.isSome()).toBe(true); + expect(someOption.isNone()).toBe(false); + expect(someOption.unwrap()).toBe(5); + + const noneOption = safeDivide(2, 0); + expectTypeOf(noneOption).toEqualTypeOf>(); + + expect(noneOption.isSome()).toBe(false); + expect(noneOption.isNone()).toBe(true); + expect(() => noneOption.unwrap()).toThrow(UnwrapError); }); }); - describe('fromResult', () => { - it('creates a Some when Result is a Right', () => { - expect(O.fromResult(R.ok('hello world'))).toMatchOption( - O.some('hello world'), + describe('definePredicate', () => { + it('creates a function that can be used to refine the type of a value', () => { + interface Circle { + kind: 'circle'; + } + + interface Square { + kind: 'square'; + } + + type Shape = Circle | Square; + + const isCircle = Option.definePredicate( + (shape: Shape): shape is Circle => shape.kind === 'circle', ); + + const circleOption = isCircle({ kind: 'circle' }); + + expectTypeOf(circleOption).toEqualTypeOf>(); + + expect(circleOption.isSome()).toBe(true); + expect(circleOption.isNone()).toBe(false); + expect(circleOption.unwrap()).toEqual({ kind: 'circle' }); }); - it('creates a None when Result is a Left', () => { - expect(O.fromResult(R.error('Computation failure'))).toBeNone(); + it('creates a function that can be used to assert the type of a value', () => { + const isPositive = Option.definePredicate((value: number) => value > 0); + + const positiveOption = isPositive(10); + + expectTypeOf(positiveOption).toEqualTypeOf>(); + + expect(positiveOption.isSome()).toBe(true); + expect(positiveOption.isNone()).toBe(false); + expect(positiveOption.unwrap()).toBe(10); }); }); }); - describe('lifting', () => { - describe('liftNullable', () => { - function divide(dividend: number, divisor: number): number | null { - return divisor === 0 ? null : dividend / divisor; - } + describe('conversions', () => { + describe('match', () => { + it('executes the Some case if the Option is a Some', () => { + const result = Option.some(5).match({ + Some(value) { + return value * 2; + }, + None() { + return 0; + }, + }); - const safeDivide = O.liftNullable(divide); + expect(result).toBe(10); + }); - it('creates a Some when the lifted function returns a non-nullable value', () => { - expect(safeDivide(10, 2)).toMatchOption(O.some(5)); + it('executes the None case if the Option is a None', () => { + const result = Option.none().match({ + Some(value) { + return value * 2; + }, + None() { + return 0; + }, + }); + + expect(result).toBe(0); }); + }); - it('creates a None when the lifted function returns null or undefined', () => { - expect(safeDivide(2, 0)).toBeNone(); + describe('unwrap', () => { + it('returns the value of the Option if it is a Some', () => { + const result = Option.some(10).unwrap(); + + expect(result).toBe(10); + }); + + it('throws an Error if the Option is a None', () => { + const option = Option.none(); + + expect(() => option.unwrap()).toThrow(UnwrapError); }); }); - describe('liftThrowable', () => { - const safeJsonParse = O.liftThrowable(JSON.parse); + describe('unwrapOr', () => { + it('returns the value of the Option if it is a Some', () => { + const result = Option.some(10).unwrapOr(() => 0); - it('creates a Some when the lifted function succeeds', () => { - expect(safeJsonParse('{ "enabled": true }')).toMatchOption( - O.some({ enabled: true }), - ); + expect(result).toBe(10); }); - it('creates a None when the lifted function throws an exception', () => { - expect(safeJsonParse('{{ }}')).toBeNone(); + it('returns the fallback value if the Option is a None', () => { + const result = Option.none().unwrapOr(() => 0); + + expect(result).toBe(0); }); }); - }); - describe('replacements', () => { - describe('fallback', () => { - it('does not replace the original Option when it’s a Some', () => { - expect(O.some('a').pipe(O.fallback(() => O.some('b')))).toMatchOption( - O.some('a'), + describe('expect', () => { + it('returns the value of the Option if it is a Some', () => { + const result = Option.some(10).expect('Missing value'); + + expect(result).toBe(10); + }); + + it('throws a custom Error if the Option is a None', () => { + class MissingValue extends Error { + readonly _tag = 'MissingValue'; + } + + const option = Option.none(); + + expect(() => option.expect(() => new MissingValue())).toThrow( + MissingValue, ); }); - it('replaces the original Option with the provided fallback when it’s a None', () => { - expect(O.none().pipe(O.fallback(() => O.some('b')))).toMatchOption( - O.some('b'), + it('throws an UnexpectedOptionError if the Option is a None and a string is provided', () => { + const option = Option.none(); + + expect(() => option.expect('Value must be provided')).toThrow( + new UnexpectedOptionException('Value must be provided'), ); }); }); - }); - describe('transforming', () => { - describe('map', () => { - it('maps the value to another value if Option is a Some', () => { - expect( - O.some('hello').pipe(O.map((greeting) => `${greeting} world`)), - ).toMatchOption(O.some('hello world')); + describe('toNullable', () => { + it('returns the value if the Option is a Some', () => { + const result = Option.some(10).toNullable(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(10); }); - it('is a no-op if Option is a None', () => { - expect( - O.none().pipe(O.map((greeting: string) => `${greeting} world`)), - ).toBeNone(); + it('returns null if the Option is a None', () => { + const result = Option.none().toNullable(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(null); }); }); - describe('flatMap', () => { - const transformToAnotherOption = flow( - S.length, - O.fromPredicate((length) => length >= 5), - ); + describe('toUndefined', () => { + it('returns the value if the Option is a Some', () => { + const result = Option.some(10).toUndefined(); - it('maps the value if Option is a Some and flattens the result to a single Option', () => { - expect( - O.some('hello').pipe(O.flatMap(transformToAnotherOption)), - ).toMatchOption(O.some(5)); + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(10); }); - it('is a no-op if Option is a None', () => { - expect(O.none().pipe(O.flatMap(transformToAnotherOption))).toBeNone(); + it('returns undefined if the Option is a None', () => { + const result = Option.none().toUndefined(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(undefined); }); }); - describe('flatMapNullable', () => { - interface Profile { - address?: { - home: string | null; - work: string | null; - }; - } + describe('contains', () => { + it('returns true if the Option is a Some and the predicate is fulfilled', () => { + const result = Option.some(10).contains((value) => value > 0); - const profile: Profile = { - address: { - home: '21st street', - work: null, - }, - }; - - it('flat maps into a Some if returning value is not nullable', () => { - expect( - O.fromNullable(profile.address).pipe( - O.flatMapNullable((address) => address.home), - ), - ).toMatchOption(O.some('21st street')); + expect(result).toBe(true); }); - it('flat maps into a None if returning value is nullable', () => { - expect( - O.fromNullable(profile.address).pipe( - O.flatMapNullable((address) => address.work), - ), - ).toBeNone(); + it('returns false if the Option is a Some and the predicate is not fulfilled', () => { + const result = Option.some(10).contains((value) => value === 0); + + expect(result).toBe(false); }); - }); - describe('flatten', () => { - const transformToAnotherOption = flow( - S.length, - O.fromPredicate((length) => length >= 5), - ); + it('returns false if the Option is a None', () => { + const result = Option.none().contains((value) => value > 0); - it('flattens an Option of an Option into a single Option', () => { - expect( - O.some('hello').pipe(O.map(transformToAnotherOption), O.flatten), - ).toMatchOption(O.some(5)); + expect(result).toBe(false); }); }); }); - describe('filtering', () => { - describe('filter', () => { - it('keeps the Option value if it matches the predicate', () => { - expect(O.some('hello').pipe(O.filter(S.isString))).toMatchOption( - O.some('hello'), + describe('transformations', () => { + describe('map', () => { + it('transforms the Some value', () => { + const result = Option.some('hello world').map((value) => + value.toUpperCase(), ); - }); - it('filters the Option value out if it doesn’t match the predicate', () => { - expect(O.some('hello').pipe(O.filter(N.isNumber))).toBeNone(); + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + expect(result.unwrap()).toBe('HELLO WORLD'); }); - it('is a no-op if Option is a None', () => { - expect(O.none().pipe(O.filter(S.isString))).toBeNone(); + it('has no effect if the Option is a None', () => { + const result = Option.none().map((value) => + value.toUpperCase(), + ); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(false); + expect(result.isNone()).toBe(true); + expect(() => result.unwrap()).toThrow(UnwrapError); }); - }); - }); - describe('getters', () => { - describe('match', () => { - it('returns the result of the onNone function if Option is a None', () => { - expect( - O.none().pipe( - O.match( - () => 'no one to greet', - (greeting: string) => `${greeting} world`, - ), - ), - ).toBe('no one to greet'); - }); - - it('passes the Option value if it’s a Some into the onSome function and returns its result', () => { - expect( - O.some('hello').pipe( - O.match( - () => 'no one to greet', - (greeting) => `${greeting} world`, - ), - ), - ).toBe('hello world'); + it('tells the developer to use andThen when returning an Option', () => { + const result = Option.some('hello world').map((value) => + // @ts-expect-error this is testing the error message + Option.fromFalsy(value.toUpperCase()), + ); + + expectTypeOf(result).toEqualTypeOf>(); }); }); - describe('getOrElse', () => { - it('unwraps the Option value if it’s a Some', () => { - expect(O.some('hello').pipe(O.getOrElse(() => 'no one to greet'))).toBe( - 'hello', + describe('andThen', () => { + it('transforms the Some value while flattening the Option', () => { + const result = Option.some('hello world').andThen((value) => + Option.some(value.length), ); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + expect(result.unwrap()).toBe(11); }); - it('returns the result of the onNone function if Option is a None', () => { - expect(O.none().pipe(O.getOrElse(() => 'no one to greet'))).toBe( - 'no one to greet', - ); + it('has no effect when Option is a None and flattens the Option', () => { + const result = Option.none().andThen(() => Option.some(10)); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(false); + expect(result.isNone()).toBe(true); + expect(() => result.unwrap()).toThrow(UnwrapError); }); }); - describe('unwrap', () => { - it('unwraps the Option value if it’s a Some', () => { - expect(O.some('hello').pipe(O.unwrap)).toBe('hello'); + describe('filter', () => { + it('keeps the Some Option if the predicate is fulfilled', () => { + const result = Option.some(10).filter((value) => value > 0); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + expect(result.unwrap()).toBe(10); }); - it('throws an exception if Option is a None', () => { - expect(() => O.none().pipe(O.unwrap)).toThrow( - new Error('Failed to unwrap Option value'), - ); + it('transforms the Some Option into a None Option if the predicate is not fulfilled', () => { + const result = Option.some(10).filter((value) => value <= 0); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(false); + expect(result.isNone()).toBe(true); + expect(() => result.unwrap()).toThrow(UnwrapError); + }); + + it('has no effect if the Option is a None', () => { + const result = Option.none().filter((value) => value > 0); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(false); + expect(result.isNone()).toBe(true); + expect(() => result.unwrap()).toThrow(UnwrapError); }); }); + }); - describe('expect', () => { - class NotFoundException extends Error {} + describe('fallbacks', () => { + describe('or', () => { + it('returns the Some value if the Option is a Some', () => { + const result = Option.some(10).or(() => Option.some(20)); - it('unwraps the Option value if it’s a Some', () => { - expect( - O.some('hello').pipe( - O.expect(() => new NotFoundException('Greeting not found')), - ), - ).toBe('hello'); + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + expect(result.unwrap()).toBe(10); }); - it('throws an exception if Option is a None', () => { - expect(() => - O.none().pipe( - O.expect(() => new NotFoundException('Greeting not found')), - ), - ).toThrow(new NotFoundException('Greeting not found')); + it('returns the fallback value if the Option is a None', () => { + const result = Option.none().or(() => Option.some(20)); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + expect(result.unwrap()).toBe(20); }); }); + }); - describe('toNullable', () => { - it('unwraps the Option value if it’s a Some', () => { - expect(O.some('hello').pipe(O.toNullable)).toBe('hello'); + describe('comparisons', () => { + describe('equals', () => { + it('returns true if the Option is a Some and the other Option is a Some and the values are equal', () => { + const result = Option.some(10).equals(Option.some(10)); + + expect(result).toBe(true); }); - it('returns null if Option is a None', () => { - expect(O.none().pipe(O.toNullable)).toBe(null); + it('returns the output of the equality function if the Option is a Some and the other Option is a Some', () => { + const isSameObject = vi.fn((a, b) => a.value === b.value); + + const result = Option.some({ value: 10 }).equals( + Option.some({ value: 20 }), + isSameObject, + ); + + expect(result).toBe(false); + expect(isSameObject).toHaveBeenCalledExactlyOnceWith( + { value: 10 }, + { value: 20 }, + ); }); - }); - describe('toUndefined', () => { - it('unwraps the Option value if it’s a Some', () => { - expect(O.some('hello').pipe(O.toUndefined)).toBe('hello'); + it('does not call the equality function if one of the Options is a None', () => { + const isSameObject = vi.fn((a, b) => a.value === b.value); + + Option.some({ value: 10 }).equals(Option.none(), isSameObject); + + expect(isSameObject).not.toHaveBeenCalled(); }); - it('returns undefined if Option is a None', () => { - expect(O.none().pipe(O.toUndefined)).toBe(undefined); + it('returns false if the Option is a Some and the other Option is a Some and the values are not equal', () => { + const result = Option.some(10).equals(Option.some(20)); + + expect(result).toBe(false); }); - }); - describe('satisfies', () => { - it('returns true if Option is a Some and value satisfies the predicate', () => { - expect( - O.some('hello').pipe( - O.satisfies((greeting) => greeting.length === 5), - ), - ).toBe(true); + it('returns false if the Option is a Some and the other Option is a None', () => { + const result = Option.some(10).equals(Option.none()); + + expect(result).toBe(false); }); - it('returns false if Option is a Some and value does’t satisfy the predicate', () => { - expect( - O.some('hello').pipe(O.satisfies((greeting) => greeting.length > 5)), - ).toBe(false); + it('returns false if the Option is a None and the other Option is a Some', () => { + const result = Option.none().equals(Option.some(20)); + + expect(result).toBe(false); }); - it('returns false if Option is a None', () => { - expect( - O.none().pipe( - O.satisfies((greeting: string) => greeting.length === 5), - ), - ).toBe(false); + it('returns true if the Option is a None and the other Option is a None', () => { + const result = Option.none().equals(Option.none()); + + expect(result).toBe(true); }); }); }); diff --git a/src/option.ts b/src/option.ts index 7df41e2..39380a2 100644 --- a/src/option.ts +++ b/src/option.ts @@ -1,566 +1,756 @@ -/* eslint-disable no-param-reassign,, prefer-destructuring */ - -import { dual } from './_internals/dual'; -import * as _ from './_internals/option'; -import type { Pipeable } from './_internals/pipeable'; -import { error, isOk, ok } from './_internals/result'; -import type { Falsy, Mutable, Nullable } from './_internals/types'; -import { constNull, constUndefined, identity, type Thunk } from './functions'; -import type { Predicate, Refinement } from './predicate'; +/* eslint-disable @typescript-eslint/no-namespace */ +import { UnexpectedOptionException, UnwrapError } from './exceptions'; +import { identity } from './functions'; +import { refEquality } from './internals/equality'; +import { INSPECT_SYMBOL } from './internals/inspect'; +import type { Falsy } from './internals/types'; +import type { EqualityFn, Predicate, Refinement } from './predicate'; import type { Result } from './result'; +import type { Lazy, Nullable } from './types'; -// ------------------------------------- -// constructors -// ------------------------------------- +const $some = Symbol('Option::Some'); +const $none = Symbol('Option::None'); -export type Option = None | Some; - -export type AsyncOption = Promise>; - -export interface Some extends Pipeable { - readonly _tag: 'Some'; - readonly value: A; +declare namespace Type { + type Some = typeof $some; + type None = typeof $none; } -export interface None extends Pipeable { - readonly _tag: 'None'; +interface PatternMatch { + Some: (value: Value) => Output; + None: () => NoneOutput; } -export const some = _.some; - -export const none = _.none; - -// ------------------------------------- -// refinements -// ------------------------------------- - -export const isSome = _.isSome; - -export const isNone = _.isNone; +type NoOptionReturnedInMapGuard = + Value extends Option ? + 'ERROR: Use `andThen` instead. Cause: the transformation is returning an Option, use `andThen` to flatten the Option.' + : Value; -export const isOption = _.isOption; - -// ------------------------------------- -// conversions -// ------------------------------------- +type InferOptionValue = + Output extends Option ? + /* removes `never` from union */ + Value extends never ? never + : /* removes `any` from union */ + unknown extends Value ? never + : Value + : never; /** - * If value is `null` or `undefined`, returns `None`. - * - * Otherwise, returns the value wrapped in a `Some`. + * `Option` represents an optional value: every `Option` is either `Some` and contains a value, or `None`, and it's empty. * - * @example - * ```ts - * import { O } from 'funkcia'; + * It is commonly used to represent the result of a function that may not return a value due to failure or missing data, such as a network request, a file read, or a database query. * - * const stringOption = O.fromNullable('hello world'); - * //^? Some - * const emptyOption = O.fromNullable(null); - * //^? None - * ``` */ -export function fromNullable(value: Nullable): Option> { - return value == null ? _.none() : _.some(value); -} +export class Option { + readonly #tag: Type.Some | Type.None; -/** - * If value is a falsy value (`undefined | null | NaN | 0 | 0n | "" (empty string) | false`), returns `None`. - * - * Otherwise, returns the value wrapped in a `Some`. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const stringOption = O.fromFalsy('hello world'); - * //^? Some - * const emptyOption = O.fromFalsy(''); - * //^? None - * ``` - */ -export function fromFalsy( - value: T | Falsy, -): Option, Falsy>> { - return value ? _.some(value as any) : _.none(); -} + readonly #value: Value; -/** - * Wraps a function that might throw an exception. If the function throws, returns a `None`. - * - * Otherwise, returns the result of the function wrapped in a `Some`. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const someOption = O.fromThrowable(() => JSON.parse('{ "enabled": true }')); - * //^? Some<{ enabled: true }> - * - * const emptyOption = O.fromThrowable(() => JSON.parse('{{ }}')); - * //^? None - * ``` - */ -export function fromThrowable(f: () => A): Option { - try { - return _.some(f()); - } catch { - return _.none(); + private constructor(tag: Type.None); + + private constructor(tag: Type.Some, value: Value); + + private constructor(tag: Type.Some | Type.None, value?: any) { + this.#tag = tag; + this.#value = value; } -} -interface FromPredicate { - (refinement: Refinement): (value: A) => Option; - (predicate: Predicate): (value: A) => Option; - (value: A, refinement: Refinement): Option; - (value: A, predicate: Predicate): Option; -} + // ------------------------ + // ---MARK: CONSTRUCTORS--- + // ------------------------ + + /** + * Constructs a `Some` Option with the provided value. + * + * Use it when to be explicit construct a `Some`. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: Option + * const option = Option.some(10); + * ``` + */ + static some(value: Value): Option { + return new Option($some, value); + } -/** - * Constructs an `Option` based on a type predicate. If the predicate evaluates to `false`, returns `None`. - * - * Otherwise, returns the value wrapped in a `Some` narrowed to the specific type. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * interface Square { - * kind: 'square'; - * size: number; - * } - * - * interface Circle { - * kind: 'circle'; - * radius: number; - * } - * - * type Shape = Square | Circle; - * - * const shape: Shape = { kind: 'square', size: 2 }; - * - * const someOption = O.fromPredicate( - * shape, - * (figure): figure is Square => figure.kind === 'square', - * ); // Some - * - * const emptyOption = O.fromPredicate( - * shape, - * (figure) => figure.kind === 'circle', - * ); // None - * ``` - * - * - * @example - * ```ts - * import { O, pipe } from 'funkcia'; - * - * interface Square { - * kind: 'square'; - * size: number; - * } - * - * interface Circle { - * kind: 'circle'; - * radius: number; - * } - * - * type Shape = Square | Circle; - * - * const shape: Shape = { kind: 'square', size: 2 }; - * - * const someOption = pipe( - * shape, - * O.fromPredicate((figure): figure is Square => figure.kind === 'square'), - * ); // Some - * - * const emptyOption = pipe( - * shape, - * O.fromPredicate((figure) => figure.kind === 'circle'), - * ); // None - * ``` - */ -export const fromPredicate: FromPredicate = dual( - 2, - (value: any, predicate: Predicate) => - predicate(value) ? _.some(value) : _.none(), -); + /** + * @alias + * Alias of `Option.some` - constructs a `Some` Option with the provided value. + * + * Useful to indicate the creation of an `Option` that is immediately going to be processed. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * declare const denominator: number; + * + * // Output: Option + * const option = Option.of(denominator) + * .filter((number) => number > 0) + * .map((number) => 10 / number); + * ``` + */ + static of = Option.some; // eslint-disable-line @typescript-eslint/member-ordering + + /** + * Constructs a `None` Option, representing an empty value. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * function divide(numerator: number, denominator: number): Option { + * if (denominator === 0) { + * return Option.none(); + * } + * + * return Option.some(numerator / denominator); + * } + * ``` + */ + static none(): Option { + return new Option($none); + } -/** - * Transforms an `Result` into an `Option` discarding the error. - * - * @example - * ```ts - * import { O, R } from 'funkcia'; - * - * const someOption = O.fromResult(R.ok(10)); - * //^? Some - * - * const emptyOption = O.fromResult(R.error('Computation failure')); - * //^? None - * ``` - */ -export function fromResult(either: Result): Option { - return isOk(either) ? _.some(either.data) : _.none(); -} + /** + * Constructs an `Option` from a nullable value. + * + * If the value is `null` or `undefined`, returns a `None`. + * Otherwise, returns a `Some` with the value. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * interface User { + * id: string; + * firstName: string; + * lastName: string | null; + * age: number; + * } + * + * // Output: Option + * const option = Option.fromNullable(user.lastName); + * ``` + */ + static fromNullable( + value: Nullable, + ): Option> { + return value == null ? Option.none() : Option.some(value); + } -interface ToResult { - (onNone: Thunk): (option: Option) => Result; - (option: Option, onNone: Thunk): Result; -} + /** + * Constructs an `Option` from a _falsy_ value. + * + * If the value is _falsy_, returns a `None`. + * Otherwise, returns a `Some` with the value. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * function getEnv(variable: string): string { + * return process.env[variable] ?? ''; + * } + * + * // Output: Option + * const option = Option.fromFalsy(getEnv('BASE_URL')); + * ``` + */ + static fromFalsy( + value: Value | Falsy, + ): Option, Falsy>> { + return (value ? Option.some(value) : Option.none()) as never; + } -export const toResult: ToResult = dual(2, (option: any, onNone: Thunk) => - isSome(option) ? ok(option.value) : error(onNone()), -); + /** + * Constructs an `Option` from a `Result`. + * + * If the `Result` is `Ok`, returns a `Some` with the value. + * Otherwise, returns a `None` Option. + * + * If the `Result` is `Ok` but its value is `null` or `undefined`, a `None` Option is returned. + * + * @example + * ```ts + * import { Option, Result } from 'funkcia'; + * + * declare const result: Result; + * + * // Output: Option + * const option = Option.fromResult(result); + * ``` + */ + static fromResult( + result: Result, + ): Option> { + return result.match({ + Ok: Option.fromNullable, + Error: () => Option.none(), + }); + } -// ------------------------------------- -// lifting -// ------------------------------------- + /** + * Constructs an `Option` from a function that may throw. + * + * If the function throws, or returns `null` or `undefined`, returns a `None`. + * Otherwise, returns a `Some` with the value. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * const url = Option.try(() => new URL('example.com')); + * // ^? Option + * ``` + */ + static try(fn: () => Value): Option> { + try { + return Option.fromNullable(fn()); + } catch { + return Option.none(); + } + } -/** - * Lifts a function that returns a nullable value into a function that returns an `Option`. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * function divide(dividend: number, divisor: number): number | null { - * return divisor === 0 ? null : dividend / divisor; - * } - * - * const safeDivide = O.liftNullable(divide); - * - * const someOption = safeDivide(10, 2); - * //^? Some - * - * const emptyOption = safeDivide(2, 0); - * //^? None - * ``` - */ -export function liftNullable( - callback: (...args: A) => Nullable, -): (...args: A) => Option> { - return (...args: A) => fromNullable(callback(...args)); -} + /** + * Utility wrapper to ensure a function always returns an `Option`. + * + * This method provides a better inference over the return of the function, + * and guarantees that the function will always return an `Option`. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // When defining a normal function allowing typescript to infer the return type, + * //the return type is always a union of `Option` and `Option` + * function greaterThanZero(value: number) { + * return value > 0 ? Option.some(value) : Option.none(); + * } + * + * // Output: Option | Option + * const option = greaterThanZero(10); + * + * // When using the `wrap` method, the return type is always `Option` + * const greaterThanZero = Option.wrap((value: number) => { + * return value > 0 ? Option.some(value) : Option.none(); + * }); + * + * // Output: Option + * const option = greaterThanZero(10); + * ``` + */ + static wrap Option | Option>( + fn: Callback, + ): ( + ...args: Parameters + ) => Option>> { + return (...args) => fn(...args); + } -/** - * Lifts a function that might throw exceptions into a function that returns an `Option`. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const safeJsonParse = O.liftThrowable(JSON.parse); - * - * const someOption = safeJsonParse('{ "enabled": true }'); - * //^? Some<{ enabled: true }> - * - * const emptyOption = safeJsonParse('{{ }}'); - * //^? None - * ``` - */ -export function liftThrowable( - callback: (...args: A) => B, -): (...args: A) => Option { - return (...args) => fromThrowable(() => callback(...args)); -} + /** + * Produces a function that returns an `Option` from a function + * that may throw or return a nullable value. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * const safeJsonParse = Option.produce(JSON.parse); + * // ^? (text: string, reviver?: Function) => Option + * + * // Output: Option + * const profile = safeJsonParse('{ "name": "John Doe" }'); + * ``` + */ + static produce( + callback: (...args: Args) => Value, + ): (...args: Args) => Option> { + return (...args) => Option.try(() => callback(...args)); + } -// ------------------------------------- -// replacements -// ------------------------------------- + /** + * Creates a function that can be used to refine the type of a value. + * + * The predicate function takes a value and returns a `Option` with either + * the narrowed value or a `None` Option. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * const isCircle = Option.definePredicate( + * (shape: Shape): shape is Circle => shape.kind === 'circle', + * ); + * + * // Output: Option + * const option = isCircle(input); + * ``` + */ + static definePredicate( + refinement: Refinement, + ): (input: Value) => Option; + + /** + * Creates a function that can be used to assert the type of a value. + * + * The predicate function takes a value and returns a `Option` with either + * the value or a `None` Option. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * const isPositive = Option.definePredicate( + * (value: number) => value > 0, + * ); + * + * // Output: Option + * const option = isPositive(input); + * ``` + */ + static definePredicate( + predicate: Predicate, + ): (input: Value) => Option; + + static definePredicate( + predicate: Predicate, + ): (input: any) => Option { + return (input) => Option.of(input).filter(predicate); + } -export function fallback( - spare: Thunk>, -): (self: Option) => Option { - return (self) => (_.isNone(self) ? spare() : self); -} + // ----------------------- + // ---MARK: CONVERSIONS--- + // ----------------------- + + /** + * Compare the `Option` against the possible patterns and then execute code based on which pattern matches. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * interface User { + * id: string; + * firstName: string; + * lastName: string | null; + * age: number; + * } + * + * declare function findUserById(id: string): Option; + * + * declare function getUserLastName(user: User): Option; + * + * // Output: string + * const userGreeting = findUserById('user_01') + * .andThen(getUserLastName) + * .match({ + * Some(lastName) { + * return `Hello, Mr. ${lastName}`; + * }, + * None() { + * return 'Hello, stranger'; + * }, + * }); + * ``` + */ + match( + cases: PatternMatch, + ): Output | NoneOutput { + return this.isSome() ? cases.Some(this.#value) : cases.None(); + } -// ------------------------------------- -// transforming -// ------------------------------------- + /** + * Unwraps the `Option` value. + * + * @throws `UnwrapError` if the `Option` is `None`. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: User + * const user = Option.some(databaseUser).unwrap(); + * + * // Uncaught exception: 'called "Option.unwrap()" on a "None" value' + * const team = Option.none().unwrap(); + * ``` + */ + unwrap(): Value { + return this.unwrapOr(() => { + throw new UnwrapError('Option'); + }); + } -/** - * Maps the `Some` side of an `Option` value to a new `Option` value. - * - * Otherwise, the operation is a noop. - * - * @example - * import { O } from 'funkcia'; - * - * const someOption = O.some('John').pipe(O.map(name => `Hello, ${name}`)); - * //^? Some<'Hello, John'> - * - * const emptyOption = O.none().pipe(O.map(name => `Hello, ${name}`)); - * //^? None - */ -export function map( - onSome: (value: A) => B, -): (self: Option) => Option { - return (self) => { - if (_.isSome(self)) { - (self as unknown as Mutable>).value = onSome(self.value); - } + /** + * Unwraps the `Option` value. + * If the Option is `None`, returns the result of the provided callback. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: 'https://docs.funkcia.io' + * const baseUrl = Option.some(process.env.BASE_URL) + * .unwrapOr(() => 'http://localhost:3000'); + * + * // Output: 'sk_test_9FK7CiUnKaU' + * const apiKey = Option.none() + * .unwrapOr(() => 'sk_test_9FK7CiUnKaU'); + * ``` + */ + unwrapOr(onNone: () => Value): Value { + return this.match({ Some: identity, None: onNone }); + } - return self as Option; - }; -} + /** + * Unwraps the `Option` value. + * + * @throws `UnexpectedOptionError` with the provided message if the `Option` is `None`. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: User + * const user = Option.some(maybeUser).expect('User not found'); + * + * // Uncaught exception: 'Team not found' + * const team = Option.none().expect('Team not found'); + * ``` + */ + expect(onNone: string): Value; + + /** + * Unwraps the `Option` value. + * + * @throws the provided Error if the `Option` is `None`. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: User + * const user = Option.some(maybeUser).expect( + * () => new UserNotFound(userId) + * ); + * + * // Uncaught exception: 'Team not found: "team_01"' + * const team = Option.none().expect( + * () => new TeamNotFound('team_01') + * ); + * ``` + */ + expect(onNone: Lazy): Value; + + expect(onNone: string | Lazy): Value { + return this.unwrapOr(() => { + if (typeof onNone === 'string') { + throw new UnexpectedOptionException(onNone); + } + + throw onNone(); + }); + } -/** - * Takes a callback and injects the `Option` value as the argument, if it is a `Some`, allowing to **map** the value to some other value. - * - * Otherwise, the operation is a noop. - * - * @example - * import { O } from 'funkcia'; - * - * interface Address { - * home?: { street: string | null }; - * }; - * - * const address: Address = { home: { street: '5th Avenue' } }; - * - * const someOption = O.fromNullable(address.home).pipe(O.flatMap(home => O.fromNullable(home.street))); - * //^? Option - * - * const emptyOption = O.fromNullable(address.home).pipe(O.map(home => O.fromNullable(home.street))); - * //^? Option> - */ -export function flatMap( - onSome: (value: A) => Option, -): (self: Option) => Option { - return (self) => (_.isSome(self) ? onSome(self.value) : self); -} + /** + * Unwraps the value of the `Option` if it is a `Some`, otherwise returns `null`. + * + * Use this method at the edges of the system, when storing values in a database or serializing to JSON. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: User | null + * const user = Option.some(databaseUser).toNullable(); + * ``` + */ + toNullable(): Value | null { + return this.match({ Some: identity, None: () => null }); + } -/** - * Takes a callback and injects the `Option` value as the argument, if it is a `Some`, allowing to **map** the value to some other value. - * - * Otherwise, the operation is a noop. - * - * @example - * import { O } from 'funkcia'; - * - * interface Address { - * home?: { street: string | null }; - * }; - * - * const address: Address = { home: { street: '5th Avenue' } }; - * - * const someOption = O.fromNullable(address.home).pipe(O.flatMapNullable(home => home.street)); - * //^? Option - */ -export function flatMapNullable( - onSome: (a: A) => Nullable, -): (self: Option) => Option> { - return (self) => (_.isSome(self) ? fromNullable(onSome(self.value)) : self); -} + /** + * Unwraps the value of the `Option` if it is a `Some`, otherwise returns `undefined`. + * + * Use this method at the edges of the system, when storing values in a database or serializing to JSON. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: User | undefined + * const user = Option.some(databaseUser).toUndefined(); + * ``` + */ + toUndefined(): Value | undefined { + return this.match({ Some: identity, None: () => undefined }); + } -export const flatten: (self: Option>) => Option = - flatMap(identity); + /** + * Verifies if the `Option` contains a value that passes the test implemented by the provided function. + * + * Returns `true` if the predicate is fullfiled by the wrapped value. + * If the predicate is not fullfiled or if the `Option` is `None`, returns `false`. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: true + * const isPositive = Option.some(10).contains(num => num > 0); + * ``` + */ + contains(predicate: Predicate): boolean { + return this.isSome() && predicate(this.#value); + } -// ------------------------------------- -// filtering -// ------------------------------------- + // --------------------------- + // ---MARK: TRANSFORMATIONS--- + // --------------------------- + + /** + * Applies a callback function to the value of the `Option` when it is `Some`, + * returning a new `Option` containing the new value. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: Option + * const option = Option.some(10).map(number => number * 2); + * ``` + */ + map( + onSome: (value: Value) => NoOptionReturnedInMapGuard, + ): Option { + if (this.isNone()) { + return this as never; + } -/** - * Filters an `Option` using a predicate. - * - * If the predicate is satisfied, the original `Option` is left untouched. If the predicate is not satisfied or the `Option` is `None`, it returns `None`. - * - * @example - * import { O } from 'funkcia'; - * - * interface Square { - * kind: 'square'; - * size: number; - * } - * - * interface Circle { - * kind: 'circle'; - * radius: number; - * } - * - * type Shape = Square | Circle; - * - * const shape: Shape = { kind: 'square', size: 2 }; - * - * const someOption = O.some(shape).pipe( - * O.filter((figure): figure is Square => figure.kind === 'square'), - * ); // Some - * - * const emptyOption = O.some(shape).pipe( - * O.filter((figure): figure is Circle => figure.kind === 'circle'), - * ); // None - */ -export function filter( - refinement: Refinement, -): (self: Option) => Option; -/** - * Filters an `Option` using a predicate. - * - * If the predicate is satisfied, the original `Option` is left untouched. If the predicate is not satisfied or the `Option` is `None`, it returns `None`. - * - * @example - * import { O } from 'funkcia'; - * - * interface Square { - * kind: 'square'; - * size: number; - * } - * - * interface Circle { - * kind: 'circle'; - * radius: number; - * } - * - * type Shape = Square | Circle; - * - * const someOption = O.some({ kind: 'square', size: 2 }).pipe( - * O.filter((figure) => figure.kind === 'square'), - * ); // Some - * - * const emptyOption = O.some({ kind: 'square', size: 2 }).pipe( - * O.filter((figure) => figure.kind === 'circle'), - * ); // None - */ -export function filter( - predicate: Predicate, -): (self: Option) => Option; -export function filter( - predicate: Predicate, -): (self: Option) => Option { - return (self) => { - if (_.isNone(self)) { - return self; + // @ts-expect-error the compiler is complaining because of the NoOptionReturnedInMapGuard guard + return Option.some(onSome(this.#value)); + } + + /** + * Applies a callback function to the value of the `Option` when it is `Some`, + * and returns the new value. + * + * This is similar to map (also known as `flatMap`), with the difference + * that the callback must return an `Option`, not a raw value. + * This allows chaining multiple calls that return `Option`s together. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * interface User { + * id: string; + * firstName: string; + * lastName: string | null; + * age: number; + * } + * + * declare function findUserById(id: string): Option; + * + * declare function getUserLastName(user: User): Option; + * + * // Output: Option + * const option = findUserById('user_01').andThen(getUserLastName) + * ``` + */ + andThen( + onSome: (value: Value) => Option, + ): Option { + return this.isSome() ? onSome(this.#value) : (this as never); + } + + /** + * Asserts that the `Option` value passes the test implemented by the provided function, + * narrowing down the value to the provided type predicate. + * + * If the test fails, the value is filtered out of the `Option`, returning a `None` instead. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: Option + * const circle = Option.of(input).filter( + * (shape): shape is Circle => shape.kind === 'circle', + * ); + * ``` + */ + filter( + refinement: Refinement, + ): Option; + + /** + * Asserts that the `Option` value passes the test implemented by the provided function. + * + * If the test fails, the value is filtered out of the `Option`, returning a `None` instead. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * const option = Option.of(user).filter((user) => user.age >= 21); + * ``` + */ + filter(predicate: Predicate): Option; + + filter(predicate: Predicate): this { + if (this.isNone()) { + return this; } - return predicate(self.value) ? self : _.none(); - }; -} + return predicate(this.#value) ? this : (Option.none() as never); + } -// ------------------------------------- -// getters -// ------------------------------------- + // ----------------------- + // ---MARK: FALLBACKS--- + // ----------------------- + + /** + * Replaces the current `Option` with the provided fallback `Option`, when it is `None`. + * + * If the current `Option` is `Some`, it returns the current `Option`. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: 'Smith' + * const option = Option.some('Smith') + * .or(() => Option.some('John')) + * .unwrap(); + * + * + * // Output: 'John' + * const greeting = Option.none() + * .or(() => Option.some('John')) + * .unwrap(); + * ``` + */ + or(onNone: Lazy>): Option { + return this.isSome() ? this : onNone(); + } -/** - * Matches the given `Option` and returns either the provided `onNone` value or the result of the provided `onSome` - * function when passed the `Option`'s value. - * - * @example - * import { O } from 'funkcia'; - * - * const greeting = O.some('John').pipe(O.match(() => 'Greeting!', name => `Hello, ${name}`)); - * //^? 'Hello, John' - * - * const salutation = O.none().pipe(O.match(() => 'Greeting!', name => `Hello, ${name}`)); - * //^? 'Greeting!' - */ -export function match( - onNone: Thunk, - onSome: (value: A) => B, -): (self: Option) => B | C { - return (option) => (_.isSome(option) ? onSome(option.value) : onNone()); -} + // ----------------------- + // ---MARK: COMPARISONS--- + // ----------------------- + + /** + * Returns `true` is the Option contains a value. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * declare function findUserById(id: string): Option; + * + * const maybeUser = findUserById('user_01'); + * + * if (maybeUser.isSome()) { + * // Output: User + * const user = maybeUser.unwrap(); // `unwrap` will not throw + * } + * ``` + */ + isSome(): this is Option.Some { + return this.#tag === $some; + } -/** - * Unwraps the `Option` value. If `Option` is a `None`, returns the provided fallback instead. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const greeting = O.some('Hello world').pipe(O.getOrElse(() => 'Greeting!')); - * //^? 'Hello world' - * - * const salutation = O.none().pipe(O.getOrElse(() => 'Greeting!')); - * //^? 'John Doe' - * ``` - */ -export function getOrElse(onNone: Thunk): (self: Option) => A | B { - return match(onNone, identity); -} + /** + * Returns `true` is the Option is empty. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * declare function findUserByEmail(email: string): Option; + * + * const maybeUser = findUserByEmail(data.email); + * + * if (maybeUser.isNone()) { + * return await createUser(data) + * } + * ``` + */ + isNone(): this is Option.None { + return this.#tag === $none; + } -/** - * Unwraps the `Option` value. If `Option` is a `None`, throws an Error. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const greeting = O.some('Hello world').pipe(O.unwrap); - * //^? 'Hellow world' - * - * const salutation = O.none().pipe(O.unwrap); - * //^? Uncaught exception: 'Failed to unwrap Option value' - * ``` - */ -export const unwrap: (self: Option) => A = getOrElse(() => { - throw new Error('Failed to unwrap Option value'); -}); + /** + * Compares the `Option` with another `Option` and returns `true` if they are equal. + * + * By default, it uses referential equality to compare the values, + * but you can provide a custom equality function for more complex cases. + * + * @example + * ```ts + * import { Option } from 'funkcia'; + * + * // Output: true + * const option = Option.of(10).equals(Option.some(10)); + * ``` + */ + equals = ( + other: Option, + equalityFn: EqualityFn = refEquality, + ): boolean => { + try { + return equalityFn(this.unwrap(), other.unwrap()); + } catch { + return this.isNone() && other.isNone(); + } + }; -/** - * Unwraps the `Option` value. If `Option` is a `None`, throws the provided Error. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const user = O.some({ id: 'user_01' }).pipe(O.expect(() => UserNotFound('user_01'))); - * //^? User - * - * const team = O.none().pipe(O.expect(() => TeamNotFound('team_01'))); - * //^? Uncaught exception: 'Team not found: "team_01"' - * ``` - */ -export function expect( - onNone: Thunk, -): (self: Option) => A { - return getOrElse(() => { - throw onNone(); - }); + protected [INSPECT_SYMBOL] = (): string => + this.match({ + Some: (value) => `Some(${JSON.stringify(value)})`, + None: () => 'None', + }); } -/** - * Unwraps the `Option` value. If `Option` is a `None`, returns `null`. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const user = O.some({ id: 'user_01' }).pipe(O.toNullable); - * //^? User | null - * ``` - */ -export const toNullable: (self: Option) => A | null = - getOrElse(constNull); - -/** - * Unwraps the `Option` value. If `Option` is a `None`, returns `undefined`. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const user = O.some({ id: 'user_01' }).pipe(O.toUndefined); - * //^? User | undefined - * ``` - */ -export const toUndefined: (self: Option) => A | undefined = - getOrElse(constUndefined); +declare namespace Option { + interface Some { + /** @override this method is safe to call on a `Some` Option */ + unwrap: () => Value; + /** @override this method has no effect on a `Some` Option */ + unwrapOr: never; + /** @override this method will not throw the expected error on a `Some` Option, use `unwrap` instead */ + expect: never; + /** @override this method has no effect on a `Some` Option */ + toNullable: never; + /** @override this method has no effect on a `Some` Option */ + toUndefined: never; + /** @override this method has no effect on a `Some` Option */ + or: never; + /** @override this method has no effect on a `Some` Option */ + isNone: never; + } -/** - * Returns `true` if the predicate is satisfied by the wrapped value. - * - * If the predicate is not satisfied or the `Option` is `None`, it returns `false`. - * - * @example - * ```ts - * import { O } from 'funkcia'; - * - * const isPositive = O.some(10).pipe(O.satisfies(value => value > 0)); - * //^? true - * ``` - */ -export function satisfies( - predicate: Predicate, -): (self: Option) => boolean { - return (self) => _.isSome(self) && predicate(self.value); + interface None { + /** @override this method has no effect on a `None` Option */ + match: never; + /** @override this method has no effect on a `None` Option */ + unwrap: never; + /** @override this method has no effect on a `None` Option */ + expect: never; + /** @override this method has no effect on a `None` Option */ + map: never; + /** @override this method has no effect on a `None` Option */ + andThen: never; + /** @override this method has no effect on a `None` Option */ + filter: never; + /** @override this method has no effect on a `None` Option */ + contains: never; + /** @override this method has no effect on a `None` Option */ + isSome: never; + } } diff --git a/src/predicate.ts b/src/predicate.ts index 2e96075..b8cb30c 100644 --- a/src/predicate.ts +++ b/src/predicate.ts @@ -2,6 +2,11 @@ export type Predicate = (a: A) => boolean; export type Refinement = (a: A) => a is B; +export type EqualityFn = (a: A, b: A) => boolean; + +export type RefinedValue = + Exclude extends never ? A : Exclude; + /** * Returns a new function that will return the opposite boolean value of the original predicate. * diff --git a/src/result.spec.ts b/src/result.spec.ts index 0452255..d49f361 100644 --- a/src/result.spec.ts +++ b/src/result.spec.ts @@ -1,516 +1,769 @@ -/* eslint-disable max-classes-per-file */ - -import { flow, pipe } from './functions'; -import * as N from './number'; -import * as O from './option'; -import * as R from './result'; -import * as S from './string'; +import { + FailedPredicateError, + MissingValueError, + TaggedError, + UnexpectedResultError, + UnwrapError, + type UnknownError, +} from './exceptions'; +import type { Falsy } from './internals/types'; +import { Option } from './option'; +import { Result } from './result'; describe('Result', () => { - describe('conversions', () => { - describe('fromNullable', () => { - describe('data-first', () => { - it('creates an Ok when value is not nullable', () => { - expect( - R.fromNullable('hello world', () => new Error('Nullable value')), - ).toMatchResult(R.ok('hello world')); - }); + describe('constructors', () => { + describe('ok', () => { + it('creates a Result with the given value', () => { + const result = Result.ok('hello world'); - const eachCase = it.each([undefined, null]); + expectTypeOf(result).toEqualTypeOf>(); - eachCase('creates an Error when value is %s', (nullable) => { - expect( - R.fromNullable(nullable, () => new Error('Nullable value')), - ).toMatchResult(R.error(new Error('Nullable value'))); - }); + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('hello world'); }); + }); - describe('data-last', () => { - it('creates an Ok when value is not nullable', () => { - expect( - pipe( - 'hello world', - R.fromNullable(() => new Error('Nullable value')), - ), - ).toMatchResult(R.ok('hello world')); - }); + describe('of', () => { + it('creates a Result with the given value', () => { + const result = Result.of('hello world'); - const eachCase = it.each([undefined, null]); + expectTypeOf(result).toEqualTypeOf>(); - eachCase('creates an Error when value is %s', (nullable) => { - expect( - pipe( - nullable, - R.fromNullable(() => new Error('Nullable value')), - ), - ).toMatchResult(R.error(new Error('Nullable value'))); - }); + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('hello world'); }); }); - describe('fromFalsy', () => { - describe('data-first', () => { - it('creates an Ok when value is not nullable', () => { - expect( - R.fromFalsy('hello world', () => new Error('Nullable value')), - ).toMatchResult(R.ok('hello world')); - }); + describe('error', () => { + it('creates a Result with the given error', () => { + const result = Result.error('failed'); - const eachCase = it.each([null, undefined, 0, 0n, NaN, false, '']); + expectTypeOf(result).toEqualTypeOf>(); - eachCase('creates an Error when value is %s', (falsy) => { - expect( - R.fromFalsy(falsy, () => new Error('Nullable value')), - ).toMatchResult(R.error(new Error('Nullable value'))); - }); + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toBe('failed'); }); + }); - describe('data-last', () => { - it('creates an Ok when value is not nullable', () => { - expect( - pipe( - 'hello world', - R.fromFalsy(() => new Error('Nullable value')), - ), - ).toMatchResult(R.ok('hello world')); - }); + describe('fromNullable', () => { + it('creates an Ok Result when the value is not nullable', () => { + const result = Result.fromNullable('hello world'); - const eachCase = it.each([null, undefined, 0, 0n, NaN, false, '']); + expectTypeOf(result).toEqualTypeOf>(); - eachCase('creates an Error when value is %s', (falsy) => { - expect( - pipe( - falsy, - R.fromFalsy(() => new Error('Nullable value')), - ), - ).toMatchResult(R.error(new Error('Nullable value'))); - }); + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('hello world'); + }); + + it('creates an Error Result when the value is nullable', () => { + const value = null as string | null | undefined; + + { + const result = Result.fromNullable(value); + + expectTypeOf(result).toEqualTypeOf< + Result + >(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new MissingValueError()); + } + + { + const result = Result.fromNullable( + value, + () => new Error('missing value'), + ); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new Error('missing value')); + } + + { + const result = Result.fromNullable( + value, + () => new Error('null value'), + ); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new Error('null value')); + } }); }); - describe('fromPredicate', () => { - type Greeting = string & { greeting: true }; - - describe('data-first', () => { - it('creates an Ok when predicate is satisfied', () => { - expect( - R.fromPredicate( - 'hello world', - (value): value is Greeting => value.includes('hello'), - () => new Error('Empty option'), - ), - ).toMatchResult(R.ok('hello world')); - }); + describe('fromFalsy', () => { + it('creates an Ok Result when the value is not falsy', () => { + const value = 'hello world' as string | Falsy; - it('creates an Error when predicate is not satisfied', () => { - expect( - R.fromPredicate( - 'the world', - (value): value is Greeting => value.includes('hello'), - () => new Error('Empty option'), - ), - ).toMatchResult(R.error(new Error('Empty option'))); - }); + const result = Result.fromFalsy(value); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('hello world'); }); - describe('data-last', () => { - it('creates an Ok when predicate is satisfied', () => { - expect( - pipe( - 'hello world', - R.fromPredicate( - (value): value is Greeting => value.includes('hello'), - () => new Error('Empty option'), - ), - ), - ).toMatchResult(R.ok('hello world')); - }); + it('creates an Error Result when the value is falsy', () => { + const testValues = [ + '', + 0, + 0n, + null, + undefined, + false, + ] as const satisfies Falsy[]; - it('creates an Error when predicate is not satisfied', () => { - expect( - pipe( - 'the world', - R.fromPredicate( - (value): value is Greeting => value.includes('hello'), - () => new Error('Empty option'), - ), - ), - ).toMatchResult(R.error(new Error('Empty option'))); - }); + for (const value of testValues) { + const result = Result.fromFalsy(value); + + expectTypeOf(result).toEqualTypeOf< + Result + >(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new MissingValueError()); + } }); }); - describe('fromThrowable', () => { - it('creates an Ok when function succeeds', () => { - expect( - R.fromThrowable( - () => JSON.parse('{ "enabled": true }'), - () => new Error('Failed to parse JSON'), - ), - ).toMatchResult(R.ok({ enabled: true })); + describe('fromOption', () => { + it('returns Ok when Option is Some', () => { + const result = Result.fromOption(Option.some('hello world')); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBe('hello world'); }); - it('creates an Error function throws an exception', () => { - expect( - R.fromThrowable( - () => JSON.parse('{{ }} '), - () => new Error('Failed to parse JSON'), - ), - ).toMatchResult(R.error(new Error('Failed to parse JSON'))); + it('returns Error when Option is None', () => { + const result = Result.fromOption(Option.none()); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isError()).toBe(true); + expect(() => result.unwrap()).toThrow(UnwrapError); }); }); - describe('fromOption', () => { - describe('data-first', () => { - it('creates an Ok when Option is Some', () => { - expect( - R.fromOption( - O.some('hello world'), - () => new Error('Empty option'), - ), - ).toMatchResult(R.ok('hello world')); - }); + describe('try', () => { + it('creates an Ok Result when the function does not throw', () => { + const result = Result.try(() => 'hello world'); - it('creates an Error when Option is None', () => { - expect( - R.fromOption(O.none(), () => new Error('Empty option')), - ).toMatchResult(R.error(new Error('Empty option'))); - }); + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('hello world'); }); - describe('data-last', () => { - it('creates an Ok when Option is Some', () => { - expect( - pipe( - O.some('hello world'), - R.fromOption(() => new Error('Empty option')), - ), - ).toMatchResult(R.ok('hello world')); - }); + it('creates an Error Result when the function throws', () => { + { + const result = Result.try(() => { + throw new Error('computation failed'); + }); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new Error('computation failed')); + } - it('creates an Error when Option is None', () => { - expect( - pipe( - O.none(), - R.fromOption(() => new Error('Empty option')), - ), - ).toMatchResult(R.error(new Error('Empty option'))); + { + const result = Result.try( + () => { + throw new Error('computation failed'); + }, + () => new TypeError('custom error'), + ); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new TypeError('custom error')); + } + }); + }); + + describe('wrap', () => { + describe('wrap', () => { + class UnsetSetting extends TaggedError { + readonly _tag = 'UnsetSetting'; + } + + class DisabledSetting extends TaggedError { + readonly _tag = 'DisabledSetting'; + } + + function hasEnabledSetting(enabled: boolean | null) { + switch (enabled) { + case true: + return Result.ok(true as const); + case false: + return Result.error(new DisabledSetting()); + default: + return Result.error(new UnsetSetting()); + } + } + + it('returns a function with improved inference without changing behavior', () => { + const output = hasEnabledSetting(true); + + expectTypeOf(output).toEqualTypeOf< + | Result + | Result + | Result + >(); + + expect(output.isOk()).toBe(true); + expect(output.isError()).toBe(false); + expect(output.unwrap()).toBe(true); + + const wrapped = Result.wrap(hasEnabledSetting); + + const result = wrapped(true); + + expectTypeOf(result).toEqualTypeOf< + Result + >(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe(true); }); }); }); - }); - describe('lifting', () => { - describe('liftNullable', () => { - class InvalidDivisor extends Error {} + describe('produce', () => { + class InvalidDivisor extends TaggedError { + readonly _tag = 'InvalidDivisor'; + } - function divide(dividend: number, divisor: number): number | null { - return divisor === 0 ? null : dividend / divisor; + function divide(dividend: number, divisor: number): number { + if (divisor === 0) { + throw new InvalidDivisor('Divisor can’t be zero'); + } + + return dividend / divisor; } - const safeDivide = R.liftNullable( - divide, - () => new InvalidDivisor('Divisor can’t be zero'), - ); + const safeDivide = Result.produce< + Parameters, + number, + InvalidDivisor + >(divide, (e) => e as InvalidDivisor); + + it('creates an Ok Result when the lifted function does not throw', () => { + const result = safeDivide(10, 2); + + expectTypeOf(result).toEqualTypeOf>(); - it('creates an Ok when the lifted function returns a non-nullable value', () => { - expect(safeDivide(10, 2)).toMatchResult(R.ok(5)); + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe(5); }); - it('creates an Error when the lifted function returns null or undefined', () => { - expect(safeDivide(2, 0)).toMatchResult( - R.error(new InvalidDivisor('Divisor can’t be zero')), + it('creates an Error Result when the lifted function throws', () => { + const result = safeDivide(2, 0); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual( + new InvalidDivisor('Divisor can’t be zero'), ); }); }); - describe('liftThrowable', () => { - class ParsingFailure extends Error {} + describe('definePredicate', () => { + it('creates a function that can be used to refine the type of a value', () => { + interface Circle { + kind: 'circle'; + } + + interface Square { + kind: 'square'; + } - const safeJsonParse = R.liftThrowable( - JSON.parse, - () => new ParsingFailure('Failed to parse JSON'), - ); + type Shape = Circle | Square; - it('creates an Ok when the lifted function succeeds', () => { - expect(safeJsonParse('{ "enabled": true }')).toMatchResult( - R.ok({ enabled: true }), + const isCircle = Result.definePredicate( + (shape: Shape): shape is Circle => shape.kind === 'circle', ); + + const circleResult = isCircle({ kind: 'circle' }); + + expectTypeOf(circleResult).toEqualTypeOf< + Result> + >(); + + expect(circleResult.isOk()).toBe(true); + expect(circleResult.isError()).toBe(false); + expect(circleResult.unwrap()).toEqual({ kind: 'circle' }); }); - it('creates an Error when the lifted function throws an exception', () => { - expect(safeJsonParse('{{ }}')).toMatchResult( - R.error(new ParsingFailure('Failed to parse JSON')), - ); + it('creates a function that can be used to assert the type of a value', () => { + const isPositive = Result.definePredicate((value: number) => value > 0); + + const positiveResult = isPositive(10); + + expectTypeOf(positiveResult).toEqualTypeOf< + Result> + >(); + + expect(positiveResult.isOk()).toBe(true); + expect(positiveResult.isError()).toBe(false); + expect(positiveResult.unwrap()).toBe(10); }); - }); - }); - describe('replacements', () => { - describe('fallback', () => { - it('does not replace the original Result when it’s an Ok', () => { - expect(R.ok('a').pipe(R.fallback(() => R.ok('b')))).toMatchResult( - R.ok('a'), - ); + it('creates a function that can be used to refine the type of a value and accepts a custom error', () => { + class InvalidShapeError extends TaggedError { + readonly _tag = 'InvalidShapeError'; + } - expect(R.ok('a').pipe(R.fallback(() => R.error('b')))).toMatchResult( - R.ok('a'), + interface Circle { + kind: 'circle'; + } + + interface Square { + kind: 'square'; + } + + type Shape = Circle | Square; + + const isCircle = Result.definePredicate( + (shape: Shape): shape is Circle => shape.kind === 'circle', + (shape) => { + expectTypeOf(shape).toEqualTypeOf(); + + return new InvalidShapeError(shape.kind); + }, ); + + const circleResult = isCircle({ kind: 'circle' }); + + expectTypeOf(circleResult).toEqualTypeOf< + Result + >(); + + expect(circleResult.isOk()).toBe(true); + expect(circleResult.isError()).toBe(false); + expect(circleResult.unwrap()).toEqual({ kind: 'circle' }); }); - it('replaces the original Result with the provided fallback when it’s an Error', () => { - expect(R.error('a').pipe(R.fallback(() => R.ok('b')))).toMatchResult( - R.ok('b'), - ); + it('creates a function that can be used to assert the type of a value and accepts a custom error', () => { + class InvalidNumberError extends TaggedError { + readonly _tag = 'InvalidNumberError'; + } - expect(R.error('a').pipe(R.fallback(() => R.error('b')))).toMatchResult( - R.error('b'), + const isPositive = Result.definePredicate( + (value: number) => value > 0, + () => new InvalidNumberError(), ); + + const positiveResult = isPositive(10); + + expectTypeOf(positiveResult).toEqualTypeOf< + Result + >(); + + expect(positiveResult.isOk()).toBe(true); + expect(positiveResult.isError()).toBe(false); + expect(positiveResult.unwrap()).toBe(10); }); }); }); - describe('transforming', () => { - describe('map', () => { - it('maps the value to another value if Result is an Ok', () => { - expect( - R.ok('hello').pipe(R.map((greeting) => `${greeting} world`)), - ).toMatchResult(R.ok('hello world')); + describe('conversions', () => { + describe('match', () => { + it('executes the Ok callback when the Result is Ok', () => { + const result = Result.fromNullable('hello world').match({ + Ok(value) { + return value.toUpperCase(); + }, + Error(error) { + return error.message.toLowerCase(); + }, + }); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe('HELLO WORLD'); }); - it('is a no-op if Result is an Error', () => { - expect( - R.error('no one to greet').pipe( - R.map((greeting: string) => `${greeting} world`), - ), - ).toMatchResult(R.error('no one to greet')); + it('executes the Error callback when the Result is Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).match({ + Ok(value) { + return value.toLowerCase(); + }, + Error(error) { + return error.message.toUpperCase(); + }, + }); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe('COMPUTATION FAILED'); }); }); - describe('mapError', () => { - it('is a no-op if Result is an Ok', () => { - const result = R.fromPredicate( - 'hello', - (greeting) => greeting.length > 0, - () => 'invalid input', - ).pipe(R.mapError((message) => new SyntaxError(message))); + describe('unwrap', () => { + it('unwraps the Ok value when Result is an Ok', () => { + const result = Result.ok('hello world').unwrap(); + + expectTypeOf(result).toEqualTypeOf(); - expect(result).toMatchResult(R.ok('hello')); - expectTypeOf(result).toMatchTypeOf>(); + expect(result).toBe('hello world'); }); - it('maps the value to another value if Result is an Error', () => { - const result = R.fromPredicate( - 'hello', - (greeting) => greeting.length > 5, - () => 'invalid input', - ).pipe(R.mapError((message) => new SyntaxError(message))); + it('throws an Error when Result is an Error', () => { + const result = Result.error(new Error('computation failed')); - expect(result).toMatchResult(R.error(new SyntaxError('invalid input'))); - expectTypeOf(result).toMatchTypeOf>(); + expect(() => result.unwrap()).toThrow(UnwrapError); }); }); - describe('flatMap', () => { - const transformToAnotherOption = flow( - S.length, - R.fromPredicate( - (length) => length >= 5, - () => new Error('too small'), - ), - ); + describe('unwrapOr', () => { + it('returns the Ok value when the Result is Ok', () => { + const result = Result.ok('hello world').unwrapOr(() => 'fallback'); - it('maps the value if Result is an Ok and flattens the result to a single Result', () => { - expect( - R.ok('hello').pipe(R.flatMap(transformToAnotherOption)), - ).toMatchResult(R.ok(5)); + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe('hello world'); }); - it('is a no-op if Result is an Error', () => { - const result = R.error(new SyntaxError('invalid input')).pipe( - R.flatMap(transformToAnotherOption), - ); + it('executes and returns the onError callback value when the Result is Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).unwrapOr(() => 'fallback'); - expect(result).toMatchResult(R.error(new SyntaxError('invalid input'))); - expectTypeOf(result).toMatchTypeOf< - R.Result - >(); + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe('fallback'); }); }); - describe('flatMapNullable', () => { - interface Profile { - address?: { - home: string | null; - work: string | null; - }; + describe('unwrapError', () => { + it('unwraps the Error value when Result is an Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).unwrapError(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toEqual(new Error('computation failed')); + }); + + it('throws an Error when Result is an Ok', () => { + const result = Result.ok('hello world'); + + expect(() => result.unwrapError()).toThrow(UnwrapError); + }); + }); + + describe('expect', () => { + class NotFoundError extends Error { + readonly _tag = 'NotFoundError'; } - const profile: Profile = { - address: { - home: '21st street', - work: null, - }, - }; - - it('flat maps into an Ok if returning value is not nullable', () => { - expect( - R.fromNullable( - profile.address, - () => new Error('Missing profile address'), - ).pipe( - R.flatMapNullable( - (address) => address.home, - () => new Error('Missing home address'), - ), - ), - ).toMatchResult(R.ok('21st street')); - }); - - it('flat maps into an Error if returning value is nullable', () => { - expect( - R.fromNullable( - profile.address, - () => new Error('Missing profile address'), - ).pipe( - R.flatMapNullable( - (address) => address.work, - () => new Error('Missing work address'), - ), + it('returns the Ok value when Result is an Ok', () => { + const result = Result.ok('hello world').expect( + () => new NotFoundError(), + ); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe('hello world'); + }); + + it('throws a custom Error when Result is an Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ); + + expect(() => result.expect(() => new NotFoundError())).toThrow( + new NotFoundError(), + ); + }); + + it('throws an UnexpectedResultError when Result is an Error and the onError callback returns a string', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ); + + expect(() => result.expect('custom error message')).toThrow( + new UnexpectedResultError( + 'custom error message', + new Error('computation failed'), ), - ).toMatchResult(R.error(new Error('Missing work address'))); + ); }); }); - describe('flatten', () => { - const transformToAnotherOption = flow( - S.length, - R.fromPredicate( - (length) => length >= 5, - () => new Error('too small'), - ), - ); + describe('toNullable', () => { + it('returns the Ok value when Result is an Ok', () => { + const result = Result.ok('hello world').toNullable(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe('hello world'); + }); + + it('returns null when Result is an Error', () => { + const result = Result.error( + new Error('computation failed'), + ).toNullable(); + + expectTypeOf(result).toEqualTypeOf(); - it('flattens an Result of an Result into a single Result', () => { - expect( - R.ok('hello').pipe(R.map(transformToAnotherOption), R.flatten), - ).toMatchResult(R.ok(5)); + expect(result).toBe(null); }); }); - }); - describe('filtering', () => { - describe('filter', () => { - it('keeps the Ok value if it matches the predicate', () => { - expect( - R.ok('hello').pipe( - R.filter(S.isString, () => new Error('value is not string')), - ), - ).toMatchResult(R.ok('hello')); + describe('toUndefined', () => { + it('returns the Ok value when Result is an Ok', () => { + const result = Result.ok('hello world').toUndefined(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe('hello world'); }); - it('filters the Ok value out if it doesn’t match the predicate returning an Error instead', () => { - const result = R.ok('hello').pipe( - R.filter(N.isNumber, () => new Error('value is not a number')), - ); + it('returns undefined when Result is an Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).toUndefined(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(undefined); + }); + }); - expect(result).toMatchResult( - R.error(new Error('value is not a number')), + describe('contains', () => { + it('returns true when Result is an Ok and the predicate is fulfilled', () => { + const result = Result.ok('hello world').contains( + (value) => value.length > 0, ); - expectTypeOf(result).toMatchTypeOf>(); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(true); }); - it('is a no-op if Result is an Error', () => { - const result = R.error('no input').pipe( - R.filter(S.isString, () => new Error('value is not a string')), + it('returns false when Result is an Ok and the predicate is not fulfilled', () => { + const result = Result.ok('hello world').contains( + (value) => value.length === 0, ); - expect(result).toMatchResult(R.error('no input')); - expectTypeOf(result).toMatchTypeOf>(); + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(false); + }); + + it('returns false when Result is an Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).contains((value) => value.length > 0); + + expectTypeOf(result).toEqualTypeOf(); + + expect(result).toBe(false); }); }); }); - describe('getters', () => { - describe('match', () => { - it('returns the result of the onErr function if Result is an Error', () => { - expect( - R.error('no input').pipe( - R.match( - (error) => `missing greeting: ${error}`, - (greeting: string) => `${greeting} world`, - ), - ), - ).toBe('missing greeting: no input'); + describe('transformations', () => { + describe('map', () => { + it('transforms the Ok value', () => { + const result = Result.ok('hello world').map((value) => + value.toUpperCase(), + ); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('HELLO WORLD'); }); - it('passes the Result value if it’s an Ok into the onOk function and returns its result', () => { - expect( - R.ok('hello').pipe( - R.match( - (error) => `missing greeting: ${error}`, - (greeting) => `${greeting} world`, - ), - ), - ).toBe('hello world'); + it('has no effect if Result is an Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).map((value) => value.toUpperCase()); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new Error('computation failed')); }); }); - describe('merge', () => { - it('consolidates both paths into a single output', () => { - const result = R.ok('hello').pipe( - R.filter(S.isString, () => new Error('not a string')), - R.merge, + describe('mapError', () => { + it('transforms the Error value', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).mapError((error) => error.message.toUpperCase()); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toBe('COMPUTATION FAILED'); + }); + + it('has no effect if Result is an Ok', () => { + const result = Result.ok('hello world').mapError((error) => + (error as string).toUpperCase(), ); - expect(result).toBe('hello'); - expectTypeOf(result).toMatchTypeOf(); + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('hello world'); }); }); - describe('getOrElse', () => { - it('unwraps the Result value if it’s an Ok', () => { - expect(R.ok('hello').pipe(R.getOrElse(() => 'no one to greet'))).toBe( - 'hello', - ); + describe('mapBoth', () => { + it('executes the Ok callback when the Result is Ok', () => { + const result = Result.fromFalsy('hello world').mapBoth({ + Ok(value) { + return value.toUpperCase(); + }, + Error(error) { + return error.message.toLowerCase(); + }, + }); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('HELLO WORLD'); }); - it('returns the result of the onErr function if Result is an Error', () => { - expect( - R.error('no input').pipe(R.getOrElse(() => 'no one to greet')), - ).toBe('no one to greet'); + describe('executes the Error callback when the Result is Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).mapBoth({ + Ok(value) { + return value.toLowerCase(); + }, + Error(error) { + return error.message.toUpperCase(); + }, + }); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toBe('COMPUTATION FAILED'); }); }); - describe('unwrap', () => { - it('unwraps the Result value if it’s an Ok', () => { - expect(R.ok('hello').pipe(R.unwrap)).toBe('hello'); + describe('andThen', () => { + it('transforms the Ok value while flattening the Result', () => { + const result = Result.ok('hello world').andThen((value) => + Result.fromFalsy(value.toUpperCase()), + ); + + expectTypeOf(result).toEqualTypeOf>(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('HELLO WORLD'); }); - it('throws an exception if Result is an Error', () => { - expect(() => R.error('no input').pipe(R.unwrap)).toThrow( - new Error('Failed to unwrap Result value'), + it('does not transform the Error value and flattens the Result', () => { + const result = Result.error(new Error('computation failed')).andThen( + () => Result.fromNullable(null as string | null | undefined), ); + + expectTypeOf(result).toEqualTypeOf< + Result + >(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new Error('computation failed')); }); }); - describe('expect', () => { - class NotFoundException extends Error {} + describe('filter', () => { + it('keeps the Ok Result if the predicate is fulfilled', () => { + const result = Result.ok('hello world').filter( + (value) => value.length > 0, + ); - it('unwraps the Result value if it’s an Ok', () => { - expect( - R.ok('hello').pipe( - R.expect(() => new NotFoundException('Greeting not found')), - ), - ).toBe('hello'); + expectTypeOf(result).toEqualTypeOf< + Result> + >(); + + expect(result.isOk()).toBe(true); + expect(result.isError()).toBe(false); + expect(result.unwrap()).toBe('hello world'); }); - it('throws an exception if Result is an Error', () => { - expect(() => - R.error('no input').pipe( - R.expect(() => new NotFoundException('Greeting not found')), - ), - ).toThrow(new NotFoundException('Greeting not found')); + it('transforms the Ok Result into an Error Result if the predicate is not fulfilled', () => { + const result = Result.ok('hello world').filter( + (value) => value.length === 0, + ); + + expectTypeOf(result).toEqualTypeOf< + Result> + >(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual( + new FailedPredicateError('hello world'), + ); + }); + + it('has no effect if the Result is an Error', () => { + const result = Result.fromFalsy( + '', + () => new Error('computation failed'), + ).filter((value) => value.length > 0); + + expectTypeOf(result).toEqualTypeOf< + Result> + >(); + + expect(result.isOk()).toBe(false); + expect(result.isError()).toBe(true); + expect(result.unwrapError()).toEqual(new Error('computation failed')); }); }); }); diff --git a/src/result.ts b/src/result.ts index f3d6606..991946f 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,262 +1,1155 @@ -/* eslint-disable prefer-destructuring */ - -import { dual } from './_internals/dual'; -import { isSome, none, some } from './_internals/option'; -import { type Pipeable } from './_internals/pipeable'; -import * as _ from './_internals/result'; -import { type Falsy, type Mutable, type Nullable } from './_internals/types'; -import { identity, type Thunk } from './functions'; +/* eslint-disable @typescript-eslint/no-namespace */ +import { + FailedPredicateError, + MissingValueError, + UnexpectedResultError, + UnknownError, + UnwrapError, +} from './exceptions'; +import { identity } from './functions'; +import { INSPECT_SYMBOL } from './internals/inspect'; +import type { Falsy } from './internals/types'; import type { Option } from './option'; -import type { Predicate, Refinement } from './predicate'; +import type { Predicate, RefinedValue, Refinement } from './predicate'; +import type { Nullable } from './types'; -// ------------------------------------- -// constructors -// ------------------------------------- +const $ok = Symbol('Result::Ok'); +const $error = Symbol('Result::Error'); -export type Result = Err | Ok; - -export type AsyncResult = Promise>; - -export interface Ok extends Pipeable { - readonly _tag: 'Ok'; - readonly data: R; +declare namespace Type { + type Ok = typeof $ok; + type Error = typeof $error; } -export interface Err extends Pipeable { - readonly _tag: 'Error'; - readonly error: L; +interface PatternMatch { + Ok: (value: Value) => Output; + Error: (error: Error) => ErrorOutput; } -export const ok = _.ok; +type NoResultReturnInMapGuard = + Value extends Result ? + 'ERROR: Use `andThen` instead. Cause: the transformation is returning a Result, use `andThen` to flatten the Result.' + : Value; -export const error = _.error; +type InferResultValue = + Output extends Result ? + /* removes `never` from union */ + Value extends never ? never + : /* removes `any` from union */ + unknown extends Value ? never + : /* removes `undefined` from union */ + undefined extends Value ? + void // eslint-disable-line @typescript-eslint/no-invalid-void-type + : Value + : never; -// ------------------------------------- -// refinements -// ------------------------------------- +type InferResultError = + Output extends Result ? + /* removes `never` from union */ + Error extends never ? never + : /* removes `any` from union */ + unknown extends Error ? never + : Error + : never; -export const isOk = _.isOk; +/** + * Error handling with `Result`. + * + * `Result` represents the result of an operation that can either be successful (`Ok`) or return an error (`Error`). + * + * `Result` is commonly used to represent the result of a function that can fail, such as a network request, a file read, or a database query. + */ +export class Result { + readonly #tag: Type.Ok | Type.Error; -export const isError = _.isError; + readonly #value: Value; -export const isResult = _.isResult; + readonly #error: Error; -// ------------------------------------- -// conversions -// ------------------------------------- + private constructor(kind: Type.Ok, value: Value); -interface FromNullable { - ( - onNullable: Thunk, - ): (value: Nullable) => Result>; - (value: Nullable, onNullable: Thunk): Result>; -} + private constructor(kind: Type.Error, value: Error); -export const fromNullable: FromNullable = dual( - 2, - (value: any, onNullable: Thunk) => - value == null ? _.error(onNullable()) : _.ok(value), -); - -interface FromFalsy { - ( - onFalsy: Thunk, - ): (value: O | Falsy) => Result, Falsy>>; - ( - value: O | Falsy, - onFalsy: Thunk, - ): Result, Falsy>>; -} + private constructor(kind: Type.Ok | Type.Error, value?: any) { + this.#tag = kind; + this.#value = kind === $ok ? value : null; + this.#error = kind === $error ? value : null; + } -export const fromFalsy: FromFalsy = dual( - 2, - (value: any, onFalsy: Thunk) => - value ? _.ok(value) : _.error(onFalsy()), -); - -interface FromPredicate { - ( - refinement: Refinement, - onDissatisfied: (failure: Exclude) => E, - ): (value: O) => Result; - ( - predicate: Predicate, - onDissatisfied: (failure: O) => E, - ): (value: O) => Result; - ( - value: O, - refinement: Refinement, - onDissatisfied: (failure: Exclude) => E, - ): Result; - ( - value: O, - predicate: Predicate, - onDissatisfied: (failure: O) => E, - ): Result; -} + // ------------------------ + // ---MARK: CONSTRUCTORS--- + // ------------------------ -export const fromPredicate: FromPredicate = dual( - 3, - (value: any, predicate: Predicate, onErr: (failure: any) => any) => - predicate(value) ? _.ok(value) : _.error(onErr(value)), -); - -export function fromThrowable( - cb: () => E, - onException: (reason: unknown) => O, -): Result { - try { - return _.ok(cb()); - } catch (e) { - return _.error(onException(e)); + /** + * Constructs an `Ok` result with the provided value. + * + * Use it to explicit construct an `OK`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: Result + * const result = Result.ok(10); + * ``` + */ + static ok(value: Value): Result { + return new Result($ok, value); } -} -interface FromOption { - (onNone: Thunk): (option: Option) => Result; - (option: Option, onNone: Thunk): Result; -} + /** + * @alias + * Alias of `Result.ok` - constructs an `Ok` result with the provided value. + * + * Useful to indicate the creation of an `Result` that is immediately going to be processed. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * declare const denominator: number; + * + * // Output: Result + * const result = Result.of(denominator) + * .filter((number) => number > 0) + * .map((number) => 10 / number); + * ``` + */ + static of = Result.ok; // eslint-disable-line @typescript-eslint/member-ordering -export const fromOption: FromOption = dual( - 2, - (option: any, onNone: Thunk) => - isSome(option) ? _.ok(option.value) : _.error(onNone()), -); + /** + * Constructs an `Error` result with the provided error. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * function divide(numerator: number, denominator: number): Result { + * if (denominator === 0) { + * return Result.error(new InvalidDenominator('Division by zero')); + * } + * + * return Result.ok(numerator / denominator); + * } + * ``` + */ + static error(error: NonNullable): Result { + return new Result($error, error); + } -export function toOption(result: Result): Option { - return _.isOk(result) ? some(result.data) : none(); -} + /** + * Constructs a `Result` from a nullable value. + * + * If the value is `null` or `undefined`, returns an `Error` with a `MissingValueError` exception. + * Otherwise, returns an `Ok`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * interface User { + * id: string; + * firstName: string; + * lastName: string | null; + * age: number; + * } + * + * // Output: Result + * const result = Result.fromNullable(user.lastName); + * ``` + */ + static fromNullable( + value: Nullable, + ): Result, MissingValueError>; -// ------------------------------------- -// lifting -// ------------------------------------- + /** + * Constructs a `Result` from a nullable value. + * + * If the value is `null | undefined`, returns an `Error` with return of the provided `onNullable` callback. + * Otherwise, returns an `Ok` result. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * interface User { + * id: string; + * firstName: string; + * lastName: string | null; + * age: number; + * } + * + * // Output: Result + * const result = Result.fromNullable( + * user.lastName, + * () => new Error('User missing last name'), + * ); + * ``` + */ + static fromNullable( + value: Nullable, + onNullable: () => Error, + ): Result, Error>; -export function liftNullable( - callback: (...args: A) => Nullable, - onNull: () => E, -): (...args: A) => Result> { - return (...args: A) => fromNullable(callback(...args), onNull); -} + static fromNullable(value: any, onNullable?: () => any): Result { + return value == null ? + Result.error(onNullable?.() ?? new MissingValueError()) + : Result.ok(value); + } -export function liftThrowable( - callback: (...args: A) => O, - onException: (reason: unknown) => E, -): (...args: A) => Result { - return (...args) => fromThrowable(() => callback(...args), onException); -} + /** + * Constructs a `Result` from a _falsy_ value. + * + * If the value is _falsy_, returns an `Error` result with a `MissingValueError` exception. + * Otherwise, returns an `Ok` result. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * interface User { + * firstName: string; + * lastName?: string; + * age: number; + * } + * + * // Output: Result + * const result = Result.fromFalsy(user.lastName); + * ``` + */ + static fromFalsy( + value: Value | Falsy, + ): Result, Falsy>, MissingValueError>; -// ------------------------------------- -// replacements -// ------------------------------------- + /** + * Constructs a `Result` from a _falsy_ value. + * + * If the value is _falsy_, returns an `Error` result with the return of the provided `onFalsy` callback. + * Otherwise, returns an `Ok` result. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * interface User { + * firstName: string; + * lastName?: string; + * age: number; + * } + * + * // Output: Result + * const result = Result.fromFalsy( + * user.lastName, + * () => new Error('User missing last name'), + * ); + * ``` + */ + static fromFalsy( + value: Value | Falsy, + onFalsy: (value: Falsy) => Error, + ): Result, Falsy>, Error>; -export function fallback( - spare: Thunk>, -): (self: Result) => Result { - return (self) => (_.isOk(self) ? self : spare()); -} + static fromFalsy( + value: any, + onFalsy?: (value: any) => any, + ): Result { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!value) { + return Result.error(onFalsy?.(value) ?? new MissingValueError()); + } -// ------------------------------------- -// mappers -// ------------------------------------- + return Result.ok(value); + } + + /** + * Constructs a `Result` from a `Option`. + * + * If the `Option` is `Some`, returns an `Ok` with the value. + * Otherwise, returns an `Error` with an `MissingValueError`. + * + * @example + * ```ts + * import { Option, Result } from 'funkcia'; + * + * declare const option: Option; + * + * // Output: Result + * const result = Result.fromOption(option); + * ``` + */ + static fromOption( + option: Option, + ): Result; + + /** + * Constructs a `Result` from a `Option`. + * + * If the `Option` is `Some`, returns an `Ok` with the value. + * Otherwise, returns an `Error` with the return of the provided `onNone` callback. + * + * @example + * ```ts + * import { Option, Result } from 'funkcia'; + * + * declare const option: Option; + * + * // Output: Result + * const result = Result.fromOption(option, () => new UserNotFound()); + * ``` + */ + static fromOption( + option: Option, + onNone: () => Error, + ): Result; + + static fromOption(option: Option, onNone?: () => any): Result { + return option.match({ + Some: (value) => Result.ok(value), + None: () => Result.error(onNone?.() ?? new MissingValueError()), + }); + } -export function map( - onOk: (success: O) => O2, -): (self: Result) => Result { - return (self) => { - if (_.isOk(self)) { - (self as unknown as Mutable>).data = onOk(self.data); + /** + * Constructs a `Result` from a function that may throw. + * + * If the function does not throw, returns an `Ok` result. + * Otherwise, returns an `Error` result with an `UnknownError` containing the exception thrown by the function. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: Result + * const url = Result.try(() => new URL('example.com')); + * ``` + */ + static try(fn: () => Value): Result; + + /** + * Constructs a `Result` from a function that may throw. + * + * If the function does not throw, returns an `Ok` result. + * Otherwise, returns an `Error` result with the return of the provided `onThrow` callback. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: Result + * const url = Result.try( + * () => new URL('example.com'), + * (error) => new Error('Invalid URL'), + * ); + * ``` + */ + static try( + fn: () => Value, + onThrow: (error: unknown) => Error, + ): Result; + + static try( + fn: () => any, + onThrow?: (error: unknown) => any, + ): Result { + try { + return Result.ok(fn()); + } catch (e) { + return Result.error(onThrow?.(e) ?? new UnknownError(e)); } + } - return self as any; - }; -} + /** + * Utility wrapper to ensure a function always returns a `Result`. + * + * This method provides a better inference over the return of the function, + * and guarantees that the function will always return a `Result`. + * Extremally useful when your function returns multiple errors. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // When defining a normal function allowing typescript to infer the return type, + * //the return type is always a union of `Result` and `Result` + * function parseAndValidate(value: unknown) { + * try { + * const data = JSON.parse(value); + * + * const payload = schema.safeParse(data); + * + * return payload.success ? + * Result.ok(payload.data) + * : Result.error(new InvalidPayload(payload.error)); + * } catch { + * return Result.error(new InvalidJson()); + * } + * } + * + * + * // Output: Result | Result | Result + * const result = parseAndValidate(req.body); + * + * // When using the `wrap` method, the return type is always `Result` + * const parseAndValidate = Result.wrap((value: unknown) => { + * try { + * const data = JSON.parse(value); + * + * const payload = schema.safeParse(data); + * + * return payload.success ? + * Result.ok(payload.data) + * : Result.error(new InvalidPayload(payload.error)); + * } catch { + * return Result.error(new InvalidJson()); + * } + * }); + * + * // Output: Result + * const result = parseAndValidate(req.body); + * ``` + */ + static wrap Result>( + fn: Callback, + ): ( + ...args: Parameters + ) => Result< + InferResultValue>, + InferResultError> + > { + return (...args) => fn(...args); + } + + /** + * Produces a function that returns a `Result` from a function that may throw. + * + * If the function does not throw, returns an `Ok`. + * Otherwise, returns an `Error` with an `UnknownError` containing the exception thrown by the function. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: (text: string, reviver?: Function) => Result + * const safeJsonParse = Result.produce(JSON.parse); + * + * // Output: Result + * const result = safeJsonParse('{ "name": "John Doe" }'); + * ``` + */ + static produce( + callback: (...args: Args) => Value, + ): (...args: Args) => Result; + + /** + * Produces a function that returns a `Result` from a function that may throw. + * + * If the function does not throw, returns an `Ok`. + * Otherwise, returns an `Error` with the return of the provided `onThrow` callback. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: (text: string, reviver?: Function) => Result + * const safeJsonParse = Result.produce( + * JSON.parse, + * (error) => new TypeError('Invalid JSON'), + * ); + * + * // Output: Result + * const result = safeJsonParse('{ "name": "John Doe" }'); + * ``` + */ + static produce( + callback: (...args: Args) => Value, + onThrow: (error: unknown) => Error, + ): (...args: Args) => Result; + + static produce( + callback: (...args: any[]) => any, + onThrow?: (error: unknown) => any, + ): (...args: any[]) => Result { + return (...args) => Result.try(() => callback(...args), onThrow as never); + } + + /** + * Creates a function that can be used to refine the type of a value. + * + * The predicate function takes a value and returns a `Result` with either + * the narrowed value or a `FailedPredicateError` containing the failed value. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * const isCircle = Result.definePredicate( + * (shape: Shape): shape is Circle => shape.kind === 'circle', + * ); + * + * // Output: Result> + * const result = isCircle(input); + * ``` + */ + static definePredicate( + refinement: Refinement, + ): ( + input: Value, + ) => Result>>; -export function mapError( - onError: (failure: E) => E2, -): (self: Result) => Result { - return (self) => { - if (_.isError(self)) { - (self as unknown as Mutable>).error = onError(self.error); + /** + * Creates a function that can be used to assert the type of a value. + * + * The predicate function takes a value and returns a `Result` with either + * the value or a `FailedPredicateError` containing the failed value. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * const isPositive = Result.definePredicate( + * (value: number) => value > 0, + * ); + * + * // Output: Result> + * const result = isPositive(10); + * ``` + */ + static definePredicate( + predicate: Predicate, + ): (input: Value) => Result>; + + /** + * Creates a function that can be used to refine the type of a value. + * + * The predicate function takes a value and returns a `Result` with either + * the narrowed value or the error returned by the provided function. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * const isPositive = Result.definePredicate( + * (shape: Shape): shape is Circle => shape.kind === 'circle', + * (shape) => new InvalidShapeError(shape.kind), + * // ^? Square + * ); + * + * // Output: Result + * const result = isPositive(input); + * ``` + */ + static definePredicate( + refinement: Refinement, + onUnfulfilled: (input: RefinedValue) => Error, + ): (input: Value) => Result; + + /** + * Creates a function that can be used to assert the type of a value. + * + * The predicate function takes a value and returns a `Result` with either + * the value or the error returned by the provided function. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * const isPositive = Result.definePredicate( + * (value: number) => value > 0, + * (value) => new InvalidNumberError(value), + * ); + * + * // Output: Result + * const result = isPositive(10); + * ``` + */ + static definePredicate( + predicate: Predicate, + onUnfulfilled: (input: Value) => Error, + ): (input: Value) => Result; + + static definePredicate( + predicate: Predicate, + onUnfulfilled?: (input: any) => any, + ): (value: any) => Result { + return (value) => + Result.of(value).filter(predicate, onUnfulfilled as never); + } + + // ----------------------- + // ---MARK: CONVERSIONS--- + // ----------------------- + + /** + * Compare the `Result` against the possible patterns and then execute code based on which pattern matches. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * interface User { + * firstName: string; + * lastName?: string; + * age: number; + * } + * + * + * declare function findUserById(id: string): Result; + * + * declare function getUserLastName(user: User): Result; + * + * // Output: string + * const userGreeting = findUserById('user_01') + * .andThen(getUserLastName) + * .match({ + * Ok(lastName) { + * return `Hello, Mr. ${lastName}`; + * }, + * Error(error) { + * return 'Hello, stranger'; + * }, + * }); + * ``` + */ + match( + cases: PatternMatch, + ): Output | ErrorOutput { + return this.isOk() ? cases.Ok(this.#value) : cases.Error(this.#error); + } + + /** + * Unwraps the `Result` value. + * + * @throws `UnwrapError` if the `Result` is `Error`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: User + * const user = Result.ok(databaseUser).unwrap(); + * + * // Uncaught exception: 'called "Result.unwrap()" on an "Error" value' + * const team = Result.error(new TeamNotFound()).unwrap(); + * ``` + */ + unwrap(): Value { + return this.unwrapOr(() => { + throw new UnwrapError('Result'); + }); + } + + /** + * Returns the value of the `Result`. + * If the Result is `Error`, returns the fallback value. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: 'https://docs.funkcia.io' + * const baseUrl = Result.ok(process.env.BASE_URL) + * .unwrapOr(() => 'http://localhost:3000'); + * + * // Output: 'sk_test_9FK7CiUnKaU' + * const apiKey = Result.error('Missing API key') + * .unwrapOr(() => 'sk_test_9FK7CiUnKaU'); + * ``` + */ + unwrapOr(onError: (error: Error) => Value): Value { + return this.match({ Ok: identity, Error: onError }); + } + + /** + * Unwraps the `Result` error. + * + * @throws `UnwrapError` if the `Result` is `Ok`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * const result = Result.error(new UserNotFound()); + * + * if (result.isError()) { + * // Output: UserNotFound + * const error = result.unwrapError(); + * } + * ``` + */ + unwrapError(): Error { + return this.match({ + Ok: () => { + throw new UnwrapError('ResultError'); + }, + Error: identity, + }); + } + + /** + * Unwraps the `Result` value. + * + * Receives an `onError` callback that returns a message to throw + * if the `Result` is `Error` with the `UnexpectedResultError` exception. + * + * @throws the provided exception if the `Result` is `Error`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * declare function findUserById(id: string): Result; + * + * // Output: User + * const user = findUserById(userId).expect( + * 'User not found with id: ${userId}' + * ); + * ``` + */ + expect(onError: string): Value; + + /** + * Unwraps the `Result` value. + * + * Receives an `onError` callback that returns an Error to be thrown + * if the `Result` is `Error`. + * + * @throws the provided Error if the `Result` is `Error`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * declare function findUserById(id: string): Result; + * + * // Output: User + * const user = findUserById(userId).expect( + * () => new UserNotFoundError(userId) + * ); + * ``` + */ + expect( + onError: (error: Error) => Exception, + ): Value; + + expect(onError: string | ((error: Error) => globalThis.Error)): Value { + return this.unwrapOr((error) => { + if (typeof onError === 'string') { + throw new UnexpectedResultError(onError, error); + } + + throw onError(error); + }); + } + + /** + * Unwraps the value of the `Result` if it is a `Ok`, otherwise returns `null`. + * + * Use this method at the edges of the system, when storing values in a database or serializing to JSON. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: User | null + * const user = Result.ok(databaseUser).toNullable(); + * ``` + */ + toNullable(): Value | null { + return this.match({ Ok: identity, Error: () => null }); + } + + /** + * Unwraps the value of the `Result` if it is a `Ok`, otherwise returns `undefined`. + * + * Use this method at the edges of the system, when storing values in a database or serializing to JSON. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: User | undefined + * const user = Result.ok(databaseUser).toUndefined(); + * ``` + */ + toUndefined(): Value | undefined { + return this.match({ Ok: identity, Error: () => undefined }); + } + + /** + * Returns `true` if the predicate is fullfiled by the wrapped value. + * + * If the predicate is not fullfiled or the `Result` is `Error`, it returns `false`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: true + * const isPositive = Result.ok(10).contains(num => num > 0); + * + * // Output: false + * const isNegative = Result.error(10).contains(num => num > 0); + * ``` + */ + contains(predicate: Predicate): boolean { + return this.isOk() && predicate(this.#value); + } + + // --------------------------- + // ---MARK: TRANSFORMATIONS--- + // --------------------------- + + /** + * Applies a callback function to the value of the `Result` when it is `Ok`, + * returning a new `Result` containing the new value. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Result + * const result = Result.ok(10).map(number => number * 2); + * ``` + */ + map( + onOk: (value: Value) => NoResultReturnInMapGuard, + ): Result { + if (this.isError()) { + return this as never; } - return self as any; - }; -} + // @ts-expect-error the compiler is complaining because of the NoResultReturnInMapGuard guard + return Result.ok(onOk(this.#value)); + } -export function flatMap( - onOk: (success: O) => Result, -): (self: Result) => Result { - return (self) => (_.isOk(self) ? onOk(self.data) : self); -} + /** + * Applies a callback function to the value of the `Result` when it is `Ok`, + * and returns the new value. + * + * This is similar to map (also known as `flatMap`), with the difference + * that the callback must return an `Result`, not a raw value. + * This allows chaining multiple calls that return `Result`s together. + * + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Result + * const result = Result.fromNullable(user.lastName).mapError( + * (error) => new UserMissingInformationError() + * // ^? MissingValueError + * ); + * ``` + */ + mapError( + onError: (value: Error) => NoResultReturnInMapGuard, + ): Result { + if (this.isOk()) { + return this as never; + } -export function flatMapNullable( - onOk: (success: O) => Nullable, - onNullable: () => E2, -): (self: Result) => Result> { - return (self) => - _.isOk(self) ? fromNullable(onOk(self.data), onNullable) : self; -} + return Result.error(onError(this.#error)) as never; + } + + /** + * Maps both the `Result` value and the `Result` error to new values. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Result + * const result = Result.fromNullable(user.lastName).mapBoth({ + * Ok: (lastName) => `Hello, Mr. ${lastName}`, + * Error: (error) => new UserMissingInformationError(), + * // ^? MissingValueError + * }); + * ``` + */ + mapBoth( + cases: PatternMatch< + Value, + Error, + NoResultReturnInMapGuard, + NoResultReturnInMapGuard + >, + ): Result { + // @ts-expect-error the compiler is complaining because of the NoResultReturnInMapGuard guard + return this.isOk() ? + Result.ok(cases.Ok(this.#value)) + : Result.error(cases.Error(this.#error)); + } + + /** + * Applies a callback function to the value of the `Result` when it is `Ok`, + * and returns the new value. + * + * This is similar to map (also known as `flatMap`), with the difference + * that the callback must return an `Result`, not a raw value. + * This allows chaining multiple calls that return `Result`s together. + * + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * interface User { + * id: string; + * firstName: string; + * lastName: string | null; + * age: number; + * } + * + * declare function findUserById(id: string): Result; + * + * declare function getUserLastName(user: User): Result; + * + * // Output: Result + * const result = findUserById('user_01').andThen(getUserLastName) + * ``` + */ + andThen( + onOk: (value: Value) => Result, + ): Result { + return this.isOk() ? (onOk(this.#value) as never) : (this as never); + } + + /** + * Asserts that the `Result` value passes the test implemented by the provided function, + * narrowing down the value to the provided type predicate. + * + * If the test fails, the value is filtered out of the `Result`, returning a `Error` instead. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: Result> + * const result = Result.of(input).filter( + * (shape): shape is Circle => shape.kind === 'CIRCLE', + * ); + * ``` + */ + filter( + refinement: Refinement, + ): Result>>; + + /** + * Asserts that the `Result` value passes the test implemented by the provided function. + * + * If the test fails, the value is filtered out of the `Result`, + * returning an `Error` instead. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Result> + * const result = Result.of(user.lastName).filter( + * (value) => value.length > 0, + * ); + * ``` + */ + filter( + predicate: Predicate, + ): Result>; -export const flatten: ( - self: Result>, -) => Result = flatMap(identity); - -// ------------------------------------- -// filtering -// ------------------------------------- - -// @ts-expect-error the compiler complains about the implementation, but the overloading works fine -export function filter( - refinement: Refinement, - onDissatisfied: (value: Exclude) => E2, -): (self: Result) => Result; -export function filter( - predicate: Predicate, - onDissatisfied: (value: O) => E2, -): (self: Result) => Result; -export function filter( - predicate: (value: any) => boolean, - onDissatisfied: (value: any) => void, -) { - return (self: Result) => { - if (_.isError(self)) { - return self; + /** + * Asserts that the `Result` value passes the test implemented by the provided function, + * narrowing down the value to the provided type predicate. + * + * If the test fails, the value is filtered out of the `Result`, + * returning an `Error` Result with the provided value instead. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Output: Result + * const result = Result.of(input).filter( + * (shape): shape is Circle => shape.kind === 'CIRCLE', + * (shape) => new Error(`Expected Circle, received ${shape.kind}`), + * // ^? Square + * ); + * ``` + */ + filter( + refinement: Refinement, + onUnfulfilled: (value: RefinedValue) => NewError, + ): Result; + + /** + * Asserts that the `Result` value passes the test implemented by the provided function. + * + * If the test fails, the value is filtered out of the `Result`, + * returning an `Error` Result with the provided value instead. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * // Result + * const result = Result.of(user.lastName).filter( + * (value) => value.length > 0, + * (value) => new Error(`Expected non-empty string, received ${value}`), + * ); + * ``` + */ + filter( + predicate: Predicate, + onUnfulfilled: (value: Value) => NewError, + ): Result; + + filter( + predicate: Predicate, + onUnfulfilled?: (value: any) => any, + ): Result { + if (this.isOk() && !predicate(this.#value)) { + return Result.error( + onUnfulfilled?.(this.#value) ?? new FailedPredicateError(this.#value), + ); } - return predicate(self.data) ? self : _.error(onDissatisfied(self.data)); - }; -} + return this as never; + } -// ------------------------------------- -// getters -// ------------------------------------- + // ----------------------- + // ---MARK: COMPARISONS--- + // ----------------------- -export function match( - onErr: (error: E) => E2, - onOk: (data: O) => O2, -): (self: Result) => E2 | O2 { - return (self) => (_.isOk(self) ? onOk(self.data) : onErr(self.error)); -} + /** + * Returns `true` if the Result is `Ok`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * declare function findUserById(id: string): Result; + * + * const maybeUser = findUserById('user_01'); + * + * if (maybeUser.isSome()) { + * // Output: User + * const user = maybeUser.unwrap(); // `unwrap` will not throw + * } + * ``` + */ + isOk(): this is Result.Ok { + return this.#tag === $ok; + } -export const merge: (self: Result) => E | O = match( - identity, - identity, -); + /** + * Returns `true` if the Result is `Error`. + * + * @example + * ```ts + * import { Result } from 'funkcia'; + * + * declare function findUserByEmail(email: string): Result; + * + * const maybeUser = findUserByEmail(data.email); + * + * if (result.isError()) { + * return await createUser(data) + * } + * ``` + */ + isError(): this is Result.Error { + return this.#tag === $error; + } -export function getOrElse( - onError: (error: E) => E2, -): (self: Result) => E2 | O { - return match(onError, identity); + protected [INSPECT_SYMBOL] = (): string => + this.match({ + Ok: (value) => `Ok(${JSON.stringify(value)})`, + Error: (e) => `Error(${JSON.stringify(e)})`, + }); } -export const unwrap: (self: Result) => O = getOrElse(() => { - throw new Error('Failed to unwrap Result value'); -}); +declare namespace Result { + interface Ok { + /** + * @override `unwrap` will not throw in this context. Result value is guaranteed to exist. + */ + unwrap: () => Value; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + unwrapOr: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + unwrapError: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + match: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + mapError: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + mapBoth: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + expect: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + toNullable: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + toUndefined: never; + /** + * @override this method has no effect in this context. Result value is guaranteed to exist. + */ + isError: never; + } -export function expect( - onError: (error: E) => B, -): (self: Result) => O { - return getOrElse((e) => { - throw onError(e); - }); + interface Error extends Partial> { + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + unwrap: never; + /** + * @override `unwrapError` will not throw in this context. Result guaranteed to be Error. + */ + unwrapError: () => Err; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + expect: never; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + map: never; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + mapBoth: never; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + andThen: never; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + match: never; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + isOk: never; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + filter: never; + /** + * @override this method has no effect in this context. Result guaranteed to be Error. + */ + contains: never; + } } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..15116fa --- /dev/null +++ b/src/types.ts @@ -0,0 +1,19 @@ +import { type Option } from './option'; + +export type Lazy = () => T; + +export type Nullable = T | null | undefined; + +export type StrictOptional = { + [K in keyof T]-?: Option>; +}; + +export type Optional = { + [K in keyof T]-?: undefined extends T[K] ? Option> + : null extends T[K] ? Option> + : T[K]; +}; + +export type RemoveOptional = { + [K in keyof T]: T[K] extends Option ? U | null | undefined : T[K]; +}; diff --git a/src/uri.ts b/src/uri.ts new file mode 100644 index 0000000..48d93bc --- /dev/null +++ b/src/uri.ts @@ -0,0 +1,47 @@ +import { Result } from './result'; + +export class SafeURI { + /** + * Encodes a text string as a valid Uniform Resource Identifier (URI). + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI + */ + static encode = Result.produce< + Parameters, + string, + URIError + >(encodeURI, (e) => e as URIError); + + /** + * Gets the unencoded version of an encoded Uniform Resource Identifier (URI). + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURI + */ + static decode = Result.produce< + Parameters, + string, + URIError + >(decodeURI, (e) => e as URIError); + + /** + * Encodes a text string as a valid component of a Uniform Resource Identifier (URI). + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent + */ + static encodeURIComponent = Result.produce< + Parameters, + string, + URIError + >(encodeURIComponent, (e) => e as URIError); + + /** + * Gets the unencoded version of an encoded component of a Uniform Resource Identifier (URI). + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent + */ + static decodeURIComponent = Result.produce< + Parameters, + string, + URIError + >(decodeURIComponent, (e) => e as URIError); +} diff --git a/src/url.ts b/src/url.ts new file mode 100644 index 0000000..00356f8 --- /dev/null +++ b/src/url.ts @@ -0,0 +1,15 @@ +import { Result } from './result'; + +export class SafeURL { + /** + * The URL() constructor returns a newly created URL object representing the URL defined by the parameters. + * + * If the given base URL or the resulting URL are not valid URLs, an Error Result with the JavaScript TypeError exception is returned. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/URL/URL + */ + static of = Result.produce, URL, TypeError>( + (...args: ConstructorParameters) => new URL(args[0], args[1]), + (e) => e as TypeError, + ); +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 7b2f90f..369195f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "noEmit": false, - "target": "ES2020", + "target": "ES2022", "module": "ESNext" }, "include": ["src"] diff --git a/tsup.config.ts b/tsup.config.ts index 59dbe62..a60fe94 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -4,8 +4,17 @@ import { defineConfig } from 'tsup'; export default defineConfig({ name: 'funkcia', tsconfig: 'tsconfig.build.json', - entry: ['src/index.ts'], - splitting: true, + entry: [ + 'src/index.ts', + 'src/exceptions.ts', + 'src/functions.ts', + 'src/json.ts', + 'src/option.ts', + 'src/predicate.ts', + 'src/result.ts', + 'src/url.ts', + ], + // splitting: false, format: ['cjs', 'esm'], outDir: 'dist', clean: true,