├── .nvmrc ├── .node-version ├── src ├── logger │ ├── public_api.ts │ ├── public_api.spec.ts │ ├── logger.ts │ └── logger.spec.ts ├── state │ ├── public_api.ts │ ├── public_api.spec.ts │ ├── state.ts │ └── state.spec.ts ├── list │ ├── public_api.ts │ ├── list.factory.ts │ ├── list.factory.spec.ts │ ├── list.spec.ts │ └── list.ts ├── monad │ ├── public_api.ts │ ├── monad.interface.ts │ └── monad.ts ├── either │ ├── public_api.ts │ ├── either.factory.ts │ ├── either.interface.ts │ ├── either.ts │ └── either.spec.ts ├── reader │ ├── public_api.ts │ ├── public_api.spec.ts │ ├── reader.factory.ts │ ├── reader.interface.ts │ ├── README.md │ └── reader.spec.ts ├── result │ ├── transformers │ │ ├── result-to-promise.ts │ │ ├── result-to-observable.ts │ │ ├── unwrap-result.ts │ │ ├── try-catch-to-result.ts │ │ ├── result-to-promise.spec.ts │ │ ├── result-to-observable.spec.ts │ │ ├── unwrap-result.spec.ts │ │ ├── try-catch-to-result.spec.ts │ │ ├── promise-to-result.spec.ts │ │ ├── promise-to-result.ts │ │ ├── observable-to-result.spec.ts │ │ ├── try-promise-to-result.ts │ │ ├── observable-to-result.ts │ │ └── try-promise-to-result.spec.ts │ ├── public_api.ts │ ├── public_api.spec.ts │ ├── result.factory.ts │ ├── result.static.spec.ts │ ├── result.methods.spec.ts │ └── result.spec.ts ├── index.ts ├── maybe │ ├── public_api.ts │ ├── transformers │ │ ├── maybe-to-observable.spec.ts │ │ ├── promise-to-maybe.ts │ │ ├── maybe-to-observable.ts │ │ ├── maybe-to-promise.spec.ts │ │ ├── try-promise-to-maybe.ts │ │ ├── observable-to-maybe.ts │ │ ├── maybe-to-promise.ts │ │ ├── try-promise-to-maybe.spec.ts │ │ ├── promise-to-maybe.spec.ts │ │ └── observable-to-maybe.spec.ts │ ├── maybe.factory.ts │ ├── maybe.factory.spec.ts │ ├── maybe.ts │ └── maybe.interface.ts └── index.spec.ts ├── .vscode └── settings.json ├── .editorconfig ├── codecov.yml ├── scripts └── publish-prep.ts ├── rollup.config.js ├── tsconfig.json ├── tsconfig.build.json ├── LICENSE ├── circle.yml ├── .eslintrc.json ├── .gitignore ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.12.0 -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.12.0 -------------------------------------------------------------------------------- /src/logger/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './logger' 2 | -------------------------------------------------------------------------------- /src/state/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './state' 2 | -------------------------------------------------------------------------------- /src/list/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './list' 2 | export * from './list.factory' 3 | -------------------------------------------------------------------------------- /src/monad/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './monad' 2 | export * from './monad.interface' 3 | -------------------------------------------------------------------------------- /src/either/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './either' 2 | export * from './either.factory' 3 | export * from './either.interface' 4 | -------------------------------------------------------------------------------- /src/reader/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './reader' 2 | export * from './reader.factory' 3 | export * from './reader.interface' 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "jest.showCoverageOnLoad": true, 4 | "typescript.tsdk": "node_modules/typescript/lib" 5 | } 6 | -------------------------------------------------------------------------------- /src/monad/monad.interface.ts: -------------------------------------------------------------------------------- 1 | export type Map = (x: T) => U 2 | 3 | export interface IMonad { 4 | of(x: T): IMonad 5 | flatMap(fn: Map>): IMonad 6 | } 7 | -------------------------------------------------------------------------------- /src/state/public_api.spec.ts: -------------------------------------------------------------------------------- 1 | import { State } from './public_api' 2 | 3 | describe('state api', () => { 4 | it('should export', () => { 5 | expect(new State(a => [a, ''])).toBeInstanceOf(State) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/logger/public_api.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './public_api' 2 | 3 | describe('logger api', () => { 4 | it('should export', () => { 5 | expect(new Logger([], 'valie')).toBeInstanceOf(Logger) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/either/either.factory.ts: -------------------------------------------------------------------------------- 1 | import { Either } from './either' 2 | import { IEither } from './either.interface' 3 | 4 | export function either(left?: L, right?: R): IEither { 5 | return new Either(left, right) 6 | } 7 | -------------------------------------------------------------------------------- /src/monad/monad.ts: -------------------------------------------------------------------------------- 1 | import { IMonad, Map } from './monad.interface' 2 | 3 | export abstract class Monad implements IMonad { 4 | abstract of(x: T): IMonad 5 | abstract flatMap(fn: Map>): IMonad 6 | } 7 | -------------------------------------------------------------------------------- /src/list/list.factory.ts: -------------------------------------------------------------------------------- 1 | import { List } from './list' 2 | 3 | export function listOf(...args: T[]): List { 4 | return List.of(...args) 5 | } 6 | 7 | export function listFrom(value?: Iterable): List { 8 | return List.from(value) 9 | } 10 | -------------------------------------------------------------------------------- /src/result/transformers/result-to-promise.ts: -------------------------------------------------------------------------------- 1 | import { IResult } from '../result.interface' 2 | 3 | export function resultToPromise(result: IResult): Promise { 4 | return result.isOk() 5 | ? Promise.resolve(result.unwrap()) 6 | : Promise.reject(result.unwrapFail()) 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './maybe/public_api' 2 | export * from './reader/public_api' 3 | export * from './either/public_api' 4 | export * from './result/public_api' 5 | export * from './monad/public_api' 6 | export * from './list/public_api' 7 | export * from './state/public_api' 8 | export * from './logger/public_api' 9 | -------------------------------------------------------------------------------- /src/maybe/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './maybe' 2 | export * from './maybe.factory' 3 | export * from './maybe.interface' 4 | export * from './transformers/maybe-to-promise' 5 | export * from './transformers/maybe-to-observable' 6 | export * from './transformers/promise-to-maybe' 7 | export * from './transformers/observable-to-maybe' 8 | export * from './transformers/try-promise-to-maybe' 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | allow_coverage_offsets: true 3 | notify: 4 | require_ci_to_pass: true 5 | 6 | coverage: 7 | precision: 2 8 | round: down 9 | range: "100" 10 | 11 | status: 12 | project: 13 | default: 14 | enabled: yes 15 | threshold: 0.25% 16 | patch: 17 | default: 18 | enabled: yes 19 | target: 0% 20 | changes: false -------------------------------------------------------------------------------- /src/result/transformers/result-to-observable.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of, throwError } from 'rxjs' 2 | import { IResult } from '../result.interface' 3 | 4 | export function resultToObservable(result: IResult): Observable { 5 | if (result.isOk()) { 6 | return of(result.unwrap()) 7 | } else { 8 | return throwError(() => result.unwrapFail()) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/either/either.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IEither { 2 | isLeft(): boolean 3 | isRight(): boolean 4 | match(pattern: IEitherPattern): T 5 | tap(pattern: Partial>): void 6 | map(f: (r: R) => T): IEither 7 | flatMap(f: (r: R) => IEither): IEither 8 | } 9 | 10 | export interface IEitherPattern { 11 | left(l: L): T 12 | right(r: R): T 13 | } 14 | -------------------------------------------------------------------------------- /scripts/publish-prep.ts: -------------------------------------------------------------------------------- 1 | import { copy, ensureDir } from 'fs-extra' 2 | import { resolve } from 'path' 3 | 4 | const targetDir = resolve('dist') 5 | const srcDir = resolve('src') 6 | const filesToCopy: ReadonlyArray = [ 7 | 'package.json', 8 | 'README.md', 9 | 'LICENSE' 10 | ] 11 | 12 | ensureDir(targetDir) 13 | .then(() => Promise.all([ 14 | ...filesToCopy.map(path => copy(path, resolve(targetDir, path))), 15 | copy(srcDir, resolve(targetDir, 'src')) 16 | ])) 17 | -------------------------------------------------------------------------------- /src/result/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './result' 2 | export * from './result.factory' 3 | export * from './result.interface' 4 | export * from './transformers/result-to-promise' 5 | export * from './transformers/try-catch-to-result' 6 | export * from './transformers/unwrap-result' 7 | export * from './transformers/result-to-observable' 8 | export * from './transformers/promise-to-result' 9 | export * from './transformers/try-promise-to-result' 10 | export * from './transformers/observable-to-result' 11 | -------------------------------------------------------------------------------- /src/result/transformers/unwrap-result.ts: -------------------------------------------------------------------------------- 1 | import { map, Observable } from 'rxjs' 2 | import { IResult } from '../result.interface' 3 | 4 | export function unwrapResultAsObservable() { 5 | return function unwrapResultAsObservable1( 6 | source: Observable> 7 | ): Observable { 8 | return source.pipe( 9 | map(result => { 10 | if (result.isOk()) return result.unwrap() 11 | throw result.unwrapFail() 12 | }) 13 | ) as Observable 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/result/public_api.spec.ts: -------------------------------------------------------------------------------- 1 | import { Result, fail, ok, result, resultToPromise, resultToObservable } from './public_api' 2 | 3 | describe('result api', () => { 4 | it('should export', () => { 5 | expect(fail(Error('Test'))).toBeInstanceOf(Result) 6 | expect(ok(1)).toBeInstanceOf(Result) 7 | expect(result(() => true, 1, Error('Test'))).toBeInstanceOf(Result) 8 | expect(typeof resultToPromise).toEqual('function') 9 | expect(typeof resultToObservable).toEqual('function') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/result/result.factory.ts: -------------------------------------------------------------------------------- 1 | import { Result } from './result' 2 | import { IResult, Predicate } from './result.interface' 3 | 4 | export function ok(value: TOk): IResult { 5 | return Result.ok(value) 6 | } 7 | 8 | export function fail(value: TFail): IResult { 9 | return Result.fail(value) 10 | } 11 | 12 | export function result(predicate: Predicate, okValue: TOk, failValue: TFail): IResult { 13 | return predicate() 14 | ? ok(okValue) 15 | : fail(failValue) 16 | } 17 | -------------------------------------------------------------------------------- /src/result/transformers/try-catch-to-result.ts: -------------------------------------------------------------------------------- 1 | import { fail, ok } from '../result.factory' 2 | import { IResult } from '../result.interface' 3 | 4 | /** 5 | * Ingest a try-catch throwable function so that it doesn't halt the program but instead 6 | * returns an IResult 7 | * @param fn a throwable function 8 | * @returns an IResult object which wraps the execution as either fail or success 9 | */ 10 | export function catchResult(fn: () => TValue, errFn?: (err: unknown) => TError): IResult { 11 | try { 12 | return ok(fn()) 13 | } catch(err) { 14 | return fail(errFn ? errFn(err) : err as TError) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/list/list.factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { listFrom, listOf } from './list.factory' 2 | 3 | describe('List Factory', () => { 4 | describe(listFrom.name, () => { 5 | it('should', () => { 6 | const sut = listFrom([1, 2]).filter(a => a > 1) 7 | 8 | expect(sut.toArray()).toEqual([2]) 9 | }) 10 | 11 | it('should handle undefined', () => { 12 | const sut = listFrom().filter(a => a > 1) 13 | 14 | expect(sut.toArray()).toEqual([]) 15 | }) 16 | }) 17 | 18 | describe(listOf.name, () => { 19 | it('should handle nominal', () => { 20 | const sut = listOf(1, 2).filter(a => a > 1) 21 | 22 | expect(sut.toArray()).toEqual([2]) 23 | }) 24 | 25 | it('should handle undefined', () => { 26 | const sut = listOf().filter(a => a > 1) 27 | 28 | expect(sut.toArray()).toEqual([]) 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript' 2 | import pkg from './package.json' 3 | 4 | export default [ 5 | { 6 | input: 'dist/index.js', 7 | output: { 8 | name: 'monads', 9 | file: `dist/${pkg.main}`, 10 | format: 'umd', 11 | globals: { 12 | rxjs: 'rxjs', 13 | 'rxjs/operators': 'rxjs/operators' 14 | }, 15 | sourcemap: true 16 | }, 17 | external: [ 18 | 'rxjs', 19 | 'rxjs/operators' 20 | ] 21 | }, 22 | { 23 | input: 'src/index.ts', 24 | output: [ 25 | { file: `dist/${pkg.module}`, format: 'es', sourcemap: true }, 26 | { file: `dist/${pkg.commonJs}`, format: 'cjs', sourcemap: true } 27 | ], 28 | external: [ 29 | 'rxjs', 30 | 'rxjs/operators' 31 | ], 32 | plugins: [typescript({ tsconfig: 'tsconfig.build.json' })] 33 | }] 34 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { maybe, Maybe, either, Either, ok, fail, Result, reader, Reader, listOf, List, unwrapResultAsObservable } from './index' 2 | 3 | describe('package api', () => { 4 | it('should export maybe', () => { 5 | expect(maybe(1)).toBeInstanceOf(Maybe) 6 | }) 7 | 8 | it('should export either', () => { 9 | expect(either(1)).toBeInstanceOf(Either) 10 | }) 11 | 12 | it('should export result', () => { 13 | expect(ok(1)).toBeInstanceOf(Result) 14 | expect(fail(1)).toBeInstanceOf(Result) 15 | }) 16 | 17 | it('should export reader', () => { 18 | expect(reader(() => 1)).toBeInstanceOf(Reader) 19 | }) 20 | 21 | it('should export reader', () => { 22 | expect(listOf(1, 2)).toBeInstanceOf(List) 23 | }) 24 | 25 | it('should export unwrapResult', () => { 26 | expect(unwrapResultAsObservable()).toBeInstanceOf(Function) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/result/transformers/result-to-promise.spec.ts: -------------------------------------------------------------------------------- 1 | import { fail, ok } from '../result.factory' 2 | import { IResult } from '../result.interface' 3 | import { resultToPromise } from './result-to-promise' 4 | 5 | describe('resultToPromise', () => { 6 | it('should map', () => { 7 | const sut = new Promise>(resolve => resolve(ok('test'))) 8 | 9 | return sut 10 | .then(resultToPromise) 11 | .then(result => expect(result).toEqual('test')) 12 | .catch(() => expect(false).toBe(true)) 13 | }) 14 | 15 | it('should catch w/ fail result', () => { 16 | const sut = new Promise>(resolve => resolve(fail(new Error('test error')))) 17 | 18 | return sut 19 | .then(resultToPromise) 20 | .then(() => expect(false).toBe(true)) 21 | .catch(error => expect(error).toEqual(new Error('test error'))) 22 | }) 23 | 24 | }) 25 | -------------------------------------------------------------------------------- /src/result/transformers/result-to-observable.spec.ts: -------------------------------------------------------------------------------- 1 | import { fail, ok } from '../result.factory' 2 | import { resultToObservable } from './result-to-observable' 3 | 4 | describe(resultToObservable.name, () => { 5 | it('should be ok', done => { 6 | const result = ok('hello') 7 | const sut = resultToObservable(result) 8 | 9 | sut 10 | .subscribe({ 11 | next: v => { 12 | expect(v).toEqual('hello') 13 | done() 14 | }, 15 | error: done 16 | }) 17 | }) 18 | 19 | it('should be ok', done => { 20 | const result = fail(new Error('I failed, sorry.')) 21 | const sut = resultToObservable(result) 22 | 23 | sut 24 | .subscribe({ 25 | error: (v: Error) => { 26 | expect(v.message).toEqual('I failed, sorry.') 27 | done() 28 | }, 29 | next: done 30 | }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2015", 8 | "dom" 9 | ], 10 | "types": [ 11 | "node", 12 | "jest" 13 | ], 14 | "isolatedModules": false, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true, 17 | "noImplicitAny": true, 18 | "noImplicitUseStrict": false, 19 | "noEmitHelpers": false, 20 | "noLib": false, 21 | "noUnusedLocals": true, 22 | "strictPropertyInitialization": true, 23 | "noEmitOnError": true, 24 | "allowSyntheticDefaultImports": false, 25 | "skipLibCheck": true, 26 | "skipDefaultLibCheck": true, 27 | "strict": true, 28 | "strictNullChecks": true, 29 | "forceConsistentCasingInFileNames": true, 30 | "sourceMap": true, 31 | "downlevelIteration": true 32 | }, 33 | "include": [ 34 | "src" 35 | ] 36 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2015", 8 | "dom" 9 | ], 10 | "outDir": "dist", 11 | "downlevelIteration": true, 12 | "isolatedModules": false, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "noImplicitAny": true, 16 | "noImplicitUseStrict": false, 17 | "noEmitHelpers": false, 18 | "noLib": false, 19 | "noUnusedLocals": true, 20 | "noEmitOnError": true, 21 | "allowSyntheticDefaultImports": false, 22 | "skipLibCheck": true, 23 | "skipDefaultLibCheck": true, 24 | "strict": true, 25 | "strictNullChecks": true, 26 | "strictFunctionTypes": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "declaration": true, 29 | "declarationDir": "dist", 30 | "declarationMap": true, 31 | "sourceMap": true 32 | }, 33 | "include": [ 34 | "src/index.ts" 35 | ] 36 | } -------------------------------------------------------------------------------- /src/maybe/transformers/maybe-to-observable.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, integer, property } from 'fast-check' 2 | import { merge, of } from 'rxjs' 3 | import { count } from 'rxjs/operators' 4 | import { maybe, maybeToObservable } from '../public_api' 5 | 6 | describe('maybeToObservable', () => { 7 | const numRuns = 100 8 | it('emits a value when containing a value', () => { 9 | expect.assertions(numRuns) 10 | assert( 11 | property( 12 | integer(), 13 | a => { 14 | const m = maybe(a) 15 | const o = maybeToObservable(m) 16 | o.subscribe(val => expect(val).toBe(a)) 17 | } 18 | ), { 19 | numRuns 20 | } 21 | ) 22 | }) 23 | 24 | it('immediately completes if there is no contained value', done => { 25 | 26 | const m = maybe() 27 | const o = maybeToObservable(m) 28 | const c = of(1) 29 | 30 | merge(o, c) 31 | .pipe(count()) 32 | .subscribe(count => { 33 | expect(count).toBe(1) 34 | done() 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/result/transformers/unwrap-result.spec.ts: -------------------------------------------------------------------------------- 1 | import { map, of } from 'rxjs' 2 | import { ok, fail } from '../result.factory' 3 | import { unwrapResultAsObservable } from './unwrap-result' 4 | 5 | describe('unwrapResult', () => { 6 | it('should unwrap ok', (done) => { 7 | const sut = of(ok(1)) 8 | 9 | sut.pipe( 10 | unwrapResultAsObservable(), 11 | map(a => a + 1) 12 | ).subscribe({ 13 | next: v => { 14 | expect(v).toEqual(2) 15 | done() 16 | }, 17 | error: e => { 18 | expect('should not emit from .error').toEqual(false) 19 | done(e) 20 | } 21 | }) 22 | }) 23 | 24 | it('should unwrap fail', done => { 25 | const sut = of(fail('i failed')) 26 | 27 | sut.pipe( 28 | unwrapResultAsObservable(), 29 | map(a => a + 2) 30 | ).subscribe({ 31 | error: (v) => { 32 | expect(v).toEqual('i failed') 33 | done() 34 | }, 35 | next: val => { 36 | expect('should not emit from .next').toEqual(false) 37 | done(val) 38 | } 39 | }) 40 | }) 41 | 42 | }) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Patrick Michalina 2018-2022 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/state/state.ts: -------------------------------------------------------------------------------- 1 | 2 | export type StateTupple = [TState, TValue] 3 | 4 | export class StatePair { 5 | constructor(public readonly state: TState, public readonly value: TValue) { } 6 | } 7 | 8 | export class State { 9 | 10 | constructor(private readonly fn: (state: TState) => StateTupple) { } 11 | 12 | public of(fn: (state: TState) => StateTupple): State { 13 | return new State(fn) 14 | } 15 | 16 | public map(fn: (state: StatePair) => StateTupple): State { 17 | return new State(c => fn(this.run(c))) 18 | } 19 | 20 | public flatMap(fn: (state: StatePair) => State): State { 21 | return new State(c => { 22 | const pair = fn(this.run(c)).run(c) 23 | return [pair.state, pair.value] 24 | }) 25 | } 26 | 27 | public run(config: TState): StatePair { 28 | const tupple = this.fn(config) 29 | 30 | return new StatePair(tupple[0], tupple[1]) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/state/state.spec.ts: -------------------------------------------------------------------------------- 1 | import { State } from './state' 2 | 3 | describe(State.name, () => { 4 | 5 | it('should construct', () => { 6 | const sut = new State(state => [state, state + '_test']) 7 | .run('starting state') 8 | 9 | expect(sut.state).toEqual('starting state') 10 | expect(sut.value).toEqual('starting state_test') 11 | }) 12 | 13 | it('should of', () => { 14 | const sut = new State(state => [state, state + '_test']) 15 | .of(state => [state, 'other']) 16 | .run('starting state') 17 | 18 | expect(sut.state).toEqual('starting state') 19 | expect(sut.value).toEqual('other') 20 | }) 21 | 22 | it('should map', () => { 23 | const sut = new State(state => [state, state + '_phase1_']) 24 | .map(pair => [pair.state + '_ran_x1_3', 3]) 25 | .run('start_str') 26 | 27 | expect(sut.state).toEqual('start_str_ran_x1_3') 28 | expect(sut.value).toEqual(3) 29 | }) 30 | 31 | it('should flat map', () => { 32 | const sut = new State(state => [state, 'v1']) 33 | .flatMap(pair => new State(state => [pair.state + state, pair.value])) 34 | .run('start') 35 | 36 | expect(sut.state).toEqual('startstart') 37 | expect(sut.value).toEqual('v1') 38 | }) 39 | 40 | }) 41 | -------------------------------------------------------------------------------- /src/reader/public_api.spec.ts: -------------------------------------------------------------------------------- 1 | import { reader, readerOf, ask, asks, sequence, traverse, combine, Reader } from './public_api' 2 | 3 | describe('Reader monad public API', () => { 4 | it('should export and create instances correctly', () => { 5 | expect(reader(() => { return 1 })).toBeInstanceOf(Reader) 6 | }) 7 | 8 | it('should export all factory functions', () => { 9 | expect(reader).toBeDefined() 10 | expect(readerOf).toBeDefined() 11 | expect(ask).toBeDefined() 12 | expect(asks).toBeDefined() 13 | expect(sequence).toBeDefined() 14 | expect(traverse).toBeDefined() 15 | expect(combine).toBeDefined() 16 | }) 17 | 18 | it('should export the Reader class', () => { 19 | expect(Reader).toBeDefined() 20 | expect(Reader.of).toBeDefined() 21 | expect(Reader.ask).toBeDefined() 22 | expect(Reader.asks).toBeDefined() 23 | expect(Reader.sequence).toBeDefined() 24 | expect(Reader.traverse).toBeDefined() 25 | expect(Reader.combine).toBeDefined() 26 | }) 27 | 28 | it('should create and run Readers correctly', () => { 29 | const r1 = reader<{name: string}, string>(env => `Hello, ${env.name}!`) 30 | const r2 = asks<{name: string}, string>(env => env.name) 31 | const r3 = readerOf<{name: string}, number>(42) 32 | 33 | expect(r1.run({name: 'Alice'})).toBe('Hello, Alice!') 34 | expect(r2.run({name: 'Bob'})).toBe('Bob') 35 | expect(r3.run({name: 'Charlie'})).toBe(42) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/result/transformers/try-catch-to-result.spec.ts: -------------------------------------------------------------------------------- 1 | import { catchResult } from './try-catch-to-result' 2 | 3 | describe(catchResult.name, () => { 4 | it('should try', done => { 5 | function someThrowable(): string { 6 | return 'I worked!' 7 | } 8 | 9 | const sut = catchResult(someThrowable) 10 | 11 | expect(sut.isOk()).toEqual(true) 12 | expect(sut.unwrap()).toEqual('I worked!') 13 | 14 | done() 15 | }) 16 | 17 | it('should catch', done => { 18 | function someThrowable(): string { 19 | throw new Error('I failed!') 20 | } 21 | 22 | const sut = catchResult(someThrowable) 23 | 24 | expect(sut.isFail()).toEqual(true) 25 | 26 | done() 27 | }) 28 | 29 | it('should catch with error mapping function', done => { 30 | function someThrowable(): string { 31 | throw new Error('I failed!') 32 | } 33 | 34 | class CustomError extends Error { 35 | static fromUnknown(err: unknown): CustomError { 36 | if (err instanceof Error) { 37 | return new CustomError(err.message) 38 | } 39 | return new CustomError('new error') 40 | } 41 | constructor(message?: string) { 42 | super(message) 43 | } 44 | } 45 | 46 | const sut = catchResult(someThrowable, CustomError.fromUnknown) 47 | 48 | expect(sut.isFail()).toEqual(true) 49 | expect(sut.unwrapFail().message).toEqual('I failed!') 50 | 51 | done() 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/maybe/transformers/promise-to-maybe.ts: -------------------------------------------------------------------------------- 1 | import { maybe } from '../maybe.factory' 2 | import { IMaybe } from '../maybe.interface' 3 | 4 | /** 5 | * Converts a Promise to a Maybe monad 6 | * 7 | * Creates a Maybe from a Promise that resolves to a value. 8 | * If the promise resolves successfully, returns Some with the resolved value. 9 | * If the promise rejects or resolves to null/undefined, returns None. 10 | * 11 | * Note on resolution preservation: This transformation preserves resolution 12 | * semantics by converting: 13 | * - Promise rejections to None values (representing failure/absence) 14 | * - Promise resolutions to Some values (representing success/presence) 15 | * 16 | * The asynchronous nature is maintained by returning a Promise that resolves to a Maybe, 17 | * rather than a Maybe directly. This allows for proper handling in asynchronous chains. 18 | * 19 | * @param promise The promise to convert to a Maybe 20 | * @returns A Promise that resolves to a Maybe containing the result 21 | * 22 | * @example 23 | * // Convert a promise to a Maybe and use in a chain 24 | * api.fetchUser(userId) 25 | * .then(promiseToMaybe) 26 | * .then(userMaybe => userMaybe.match({ 27 | * some: user => console.log(user.name), 28 | * none: () => console.log('User not found') 29 | * })) 30 | */ 31 | export function promiseToMaybe(promise: Promise): Promise> { 32 | return promise 33 | .then(value => maybe(value)) 34 | .catch(() => maybe()) 35 | } 36 | -------------------------------------------------------------------------------- /src/maybe/transformers/maybe-to-observable.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY, Observable, of } from 'rxjs' 2 | import { IMaybe } from '../maybe.interface' 3 | 4 | /** 5 | * Convert a Maybe into an observable 6 | * 7 | * If the Maybe is None, the observable will immediately complete without emitting a value, 8 | * otherwise it will emit the value contained and complete. 9 | * 10 | * Note on resolution loss: This transformation loses the Maybe context. 11 | * Once converted to an Observable, None becomes an empty stream (no emissions), 12 | * rather than an emission with a special "None" value. This means: 13 | * 1. You cannot directly distinguish between an empty source and a None value 14 | * 2. Operators like `defaultIfEmpty()` are needed to handle the None case properly 15 | * 16 | * @requires rxjs@^7.0 17 | * @example 18 | * // Convert a Maybe to an Observable and subscribe 19 | * maybeToObservable(maybe(5)).subscribe( 20 | * value => console.log(value), 21 | * err => console.error(err), 22 | * () => console.log('Complete') 23 | * ) 24 | * // prints 5 and 'Complete' 25 | * 26 | * // Use with RxJS operators and handle None case 27 | * maybeToObservable(maybe(user)) 28 | * .pipe( 29 | * map(user => user.name), 30 | * defaultIfEmpty('Guest'), // Handle None case 31 | * tap(name => console.log(`Hello ${name}`)) 32 | * ) 33 | * .subscribe() 34 | */ 35 | export function maybeToObservable(m: IMaybe): Observable { 36 | return m.isNone() ? EMPTY : of(m.valueOrThrow()) 37 | } 38 | -------------------------------------------------------------------------------- /src/maybe/transformers/maybe-to-promise.spec.ts: -------------------------------------------------------------------------------- 1 | import { maybeToPromise } from './maybe-to-promise' 2 | import { maybe } from '../maybe.factory' 3 | 4 | describe('maybeToPromise', () => { 5 | it('will resolve a Some value', async () => { 6 | const m = maybe('test') 7 | const converter = maybeToPromise() 8 | const val = await converter(m) 9 | expect(val).toBe('test') 10 | }) 11 | 12 | it('will reject a None value', async () => { 13 | try { 14 | const m = maybe() 15 | const converter = maybeToPromise() 16 | await converter(m) 17 | fail('should have thrown') 18 | } catch (e) { 19 | expect(e).toBe(undefined) 20 | } 21 | }) 22 | 23 | it('will reject a None value with a value if provided', async () => { 24 | try { 25 | const m = maybe() 26 | const converter = maybeToPromise('value') 27 | await converter(m) 28 | fail('should have thrown') 29 | } catch (e) { 30 | expect(e).toBe('value') 31 | } 32 | }) 33 | 34 | it('will resolve a None value with a fallback value when handleNoneAsResolved is true', async () => { 35 | const m = maybe() 36 | const converter = maybeToPromise(null, true, 'fallback') 37 | const val = await converter(m) 38 | expect(val).toBe('fallback') 39 | }) 40 | 41 | it('will resolve a Some value normally when handleNoneAsResolved is true', async () => { 42 | const m = maybe('test') 43 | const converter = maybeToPromise(null, true, 'fallback') 44 | const val = await converter(m) 45 | expect(val).toBe('test') 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/maybe/transformers/try-promise-to-maybe.ts: -------------------------------------------------------------------------------- 1 | import { IMaybe } from '../maybe.interface' 2 | import { Maybe } from '../maybe' 3 | 4 | /** 5 | * Wraps a function that returns a Promise to catch errors and return a Maybe. 6 | * 7 | * Executes the provided function and converts the result: 8 | * - If the function's Promise resolves, returns a Some containing the result 9 | * - If the function's Promise rejects, returns None 10 | * 11 | * This is particularly useful for handling API calls or other operations that may fail, 12 | * allowing for more functional error handling without explicit try/catch blocks. 13 | * 14 | * @param fn The function that returns a Promise 15 | * @returns A Promise that resolves to a Maybe containing the result if successful 16 | * 17 | * @example 18 | * // Without using tryPromiseToMaybe 19 | * function fetchUserData(userId) { 20 | * return api.fetchUserData(userId) 21 | * .then(data => maybe(data)) 22 | * .catch(() => none()); 23 | * } 24 | * 25 | * // Using tryPromiseToMaybe 26 | * tryPromiseToMaybe(() => api.fetchUserData(userId)) 27 | * .then(userDataMaybe => { 28 | * // Then use the Maybe as normal 29 | * userDataMaybe.match({ 30 | * some: data => displayUserData(data), 31 | * none: () => showErrorMessage('Could not load user data') 32 | * }); 33 | * }); 34 | */ 35 | export function tryPromiseToMaybe(fn: () => Promise): Promise>> { 36 | return fn() 37 | .then(result => new Maybe>(result as NonNullable)) 38 | .catch(() => new Maybe>()) 39 | } 40 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | docker: 3 | - image: cimg/node:18.12.0 4 | 5 | version: 2 6 | jobs: 7 | build: 8 | <<: *defaults 9 | steps: 10 | - checkout 11 | - restore_cache: 12 | key: dependency-cache-{{ checksum "package.json" }} 13 | - run: 14 | name: Install npm 15 | command: npm ci 16 | - run: 17 | name: Build 18 | command: npm run build 19 | - run: 20 | name: Check code quality 21 | command: npm run lint 22 | - run: 23 | name: Execute Tests 24 | command: npm test 25 | - save_cache: 26 | key: dependency-cache-{{ checksum "package.json" }} 27 | paths: 28 | - node_modules 29 | semver: 30 | <<: *defaults 31 | steps: 32 | - checkout 33 | - restore_cache: 34 | key: dependency-cache-{{ checksum "package.json" }} 35 | - run: 36 | name: Install npm 37 | command: npm install 38 | - run: 39 | name: Prep Dist Folder 40 | command: npm run dist 41 | - run: 42 | name: Build 43 | command: npm run build 44 | - run: 45 | name: Semantic Release 46 | command: node_modules/.bin/semantic-release 47 | 48 | workflows: 49 | version: 2 50 | build_test_release: 51 | jobs: 52 | - build 53 | - semver: 54 | context: 55 | - SharedKeys 56 | - Test 57 | requires: 58 | - build 59 | filters: 60 | branches: 61 | only: master 62 | -------------------------------------------------------------------------------- /src/result/transformers/promise-to-result.spec.ts: -------------------------------------------------------------------------------- 1 | import { promiseToResult } from './promise-to-result' 2 | 3 | describe('promiseToResult', () => { 4 | it('should convert a resolved promise to an Ok Result', async () => { 5 | // Arrange 6 | const value = { success: true, data: 'test' } 7 | const promise = Promise.resolve(value) 8 | 9 | // Act 10 | const result = await promiseToResult(promise) 11 | 12 | // Assert 13 | expect(result.isOk()).toBe(true) 14 | expect(result.unwrap()).toEqual(value) 15 | }) 16 | 17 | it('should convert a rejected promise to a Fail Result', async () => { 18 | // Arrange 19 | const error = new Error('Test error') 20 | const promise = Promise.reject(error) 21 | 22 | // Act 23 | const result = await promiseToResult(promise) 24 | 25 | // Assert 26 | expect(result.isFail()).toBe(true) 27 | expect(result.unwrapFail()).toBe(error) 28 | }) 29 | 30 | it('should handle promises that resolve with null', async () => { 31 | // Arrange 32 | const promise = Promise.resolve(null) 33 | 34 | // Act 35 | const result = await promiseToResult(promise) 36 | 37 | // Assert 38 | expect(result.isOk()).toBe(true) 39 | expect(result.unwrap()).toBe(null) 40 | }) 41 | 42 | it('should handle promises that resolve with undefined', async () => { 43 | // Arrange 44 | const promise = Promise.resolve(undefined) 45 | 46 | // Act 47 | const result = await promiseToResult(promise) 48 | 49 | // Assert 50 | expect(result.isOk()).toBe(true) 51 | expect(result.unwrap()).toBe(undefined) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/result/transformers/promise-to-result.ts: -------------------------------------------------------------------------------- 1 | import { ok, fail } from '../result.factory' 2 | import { IResult } from '../result.interface' 3 | 4 | /** 5 | * Converts a Promise to a Result monad 6 | * 7 | * Creates a Result from a Promise: 8 | * - If the promise resolves, returns an Ok Result with the resolved value 9 | * - If the promise rejects, returns a Fail Result with the rejection reason 10 | * 11 | * Note on error typing: The error type defaults to unknown because JavaScript promises 12 | * can reject with any value. In TypeScript, you may need to use type assertions or 13 | * create a more specific version of this function if you know the exact error type. 14 | * 15 | * @param promise The promise to convert to a Result 16 | * @returns A Promise that resolves to a Result containing either the resolved value or rejection reason 17 | * 18 | * @example 19 | * // Convert a promise to a Result 20 | * fetchData() 21 | * .then(promiseToResult) 22 | * .then(result => result.match({ 23 | * ok: data => renderData(data), 24 | * fail: error => showError(error) 25 | * })); 26 | * 27 | * // With Promise chaining 28 | * promiseToResult(fetchData()) 29 | * .then(result => { 30 | * if (result.isOk()) { 31 | * const data = result.unwrap(); 32 | * return renderData(data); 33 | * } else { 34 | * const error = result.unwrapFail(); 35 | * return showError(error); 36 | * } 37 | * }); 38 | */ 39 | export function promiseToResult(promise: Promise): Promise> { 40 | return promise 41 | .then((value: T) => ok(value)) 42 | .catch((error: E) => fail(error)) 43 | } 44 | -------------------------------------------------------------------------------- /src/either/either.ts: -------------------------------------------------------------------------------- 1 | import { IEitherPattern, IEither } from './either.interface' 2 | 3 | export class Either implements IEither { 4 | constructor(private readonly left?: L, private readonly right?: R) { 5 | if (this.neitherExist()) { 6 | throw new TypeError('Either requires a left or a right') 7 | } 8 | if (this.bothExist()) { 9 | throw new TypeError('Either cannot have both a left and a right') 10 | } 11 | } 12 | 13 | private static exists(value?: T): boolean { 14 | return typeof value !== 'undefined' && value !== null 15 | } 16 | 17 | private bothExist(): boolean { 18 | return this.isLeft() && this.isRight() 19 | } 20 | 21 | private neitherExist(): boolean { 22 | return !this.isLeft() && !this.isRight() 23 | } 24 | 25 | public isLeft(): boolean { 26 | return Either.exists(this.left) 27 | } 28 | 29 | public isRight(): boolean { 30 | return Either.exists(this.right) 31 | } 32 | 33 | public match(pattern: IEitherPattern): T { 34 | return this.isRight() 35 | ? pattern.right(this.right as R) 36 | : pattern.left(this.left as L) 37 | } 38 | 39 | public tap(pattern: Partial>): void { 40 | this.isRight() 41 | ? typeof pattern.right === 'function' && pattern.right(this.right as R) 42 | : typeof pattern.left === 'function' && pattern.left(this.left as L) 43 | } 44 | 45 | public map(fn: (r: R) => T): IEither { 46 | return this.isRight() 47 | ? new Either(undefined, fn(this.right as R)) 48 | : new Either(this.left) 49 | } 50 | 51 | public flatMap(fn: (r: R) => IEither): IEither { 52 | return this.isRight() 53 | ? fn(this.right as R) 54 | : new Either(this.left) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/maybe/transformers/observable-to-maybe.ts: -------------------------------------------------------------------------------- 1 | import { Observable, EMPTY, firstValueFrom } from 'rxjs' 2 | import { catchError, take, map } from 'rxjs/operators' 3 | import { maybe } from '../maybe.factory' 4 | import { IMaybe } from '../maybe.interface' 5 | 6 | /** 7 | * Converts an Observable to a Maybe monad 8 | * 9 | * Creates a Maybe from the first value emitted by an Observable. 10 | * If the observable emits a value, returns Some with that value. 11 | * If the observable completes without emitting or errors, returns None. 12 | * 13 | * Note on resolution transformation: This function transforms the resolution semantics 14 | * in a meaningful way: 15 | * - Observable emissions (success) become Some values 16 | * - Observable completion without emissions or errors (empty success) becomes None 17 | * - Observable errors (failure) become None 18 | * 19 | * Note on timing: This function changes the timing model by taking only the first 20 | * emission and converting the asynchronous stream to a Promise of a Maybe. 21 | * The result is no longer reactive after this transformation. 22 | * 23 | * @param observable The observable to convert to a Maybe 24 | * @returns A Promise that resolves to a Maybe containing the first emitted value 25 | * 26 | * @requires rxjs@^7.0 27 | * @example 28 | * // Convert an observable to a Maybe and use in a chain 29 | * userService.getUser(userId) 30 | * .pipe(take(1)) 31 | * .toPromise() 32 | * .then(promiseToMaybe) 33 | * .then(userMaybe => userMaybe 34 | * .map(user => user.name) 35 | * .valueOr('Guest')) 36 | * .then(name => console.log(name)) 37 | */ 38 | export function observableToMaybe(observable: Observable): Promise> { 39 | return firstValueFrom( 40 | observable.pipe( 41 | take(1), 42 | map(value => maybe(value)), 43 | catchError(() => EMPTY) 44 | ) 45 | ).then( 46 | maybeValue => maybeValue, 47 | () => maybe() 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import { IMonad } from '../monad/monad.interface' 2 | 3 | /** 4 | * @name Logger 5 | * @class Perform calculation while collecting logs 6 | */ 7 | export class Logger implements IMonad { 8 | 9 | /** 10 | * @description Construct a Logger object. 11 | * @constructor 12 | * @param {TLogs[]} logs The collection of logs. 13 | * @param {TValue} value The value to wrap. 14 | */ 15 | constructor(private readonly logs: TLogs[], private readonly value: TValue) { } 16 | 17 | /** 18 | * @name Logger 19 | * @description Helper function to build a Logger object. 20 | * @static 21 | * @param {TLogs[]} story The collection of logs. 22 | * @param {TValue} value The value to wrap. 23 | * @returns {Logger} A Logger object containing the collection of logs and value. 24 | */ 25 | public static logger(logs: TLogs[], value: TValue): Logger { 26 | return new Logger(logs, value) 27 | } 28 | 29 | public static tell(s: TLogs): Logger { 30 | return new Logger([s], 0) 31 | } 32 | 33 | public static startWith(s: TLogs, value: TValue): Logger { 34 | return new Logger([s], value) 35 | } 36 | 37 | public of(v: TValue): Logger { 38 | return new Logger([], v) 39 | } 40 | 41 | public flatMap(fn: (value: TValue) => Logger): Logger { 42 | const result = fn(this.value) 43 | return new Logger(this.logs.concat(result.logs), result.value) 44 | } 45 | 46 | public flatMapPair(fn: (value: TValue) => [TLogs, TValueB]): Logger { 47 | const result = fn(this.value) 48 | return new Logger(this.logs.concat(result[0]), result[1]) 49 | } 50 | 51 | public runUsing(fn: (opts: { logs: TLogs[]; value: TValue }) => TOutput): TOutput { 52 | return fn({ logs: this.logs, value: this.value }) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/maybe/maybe.factory.ts: -------------------------------------------------------------------------------- 1 | import type { IMaybe } from './maybe.interface' 2 | import { Maybe } from './maybe' 3 | 4 | export function maybe(value?: T | null): IMaybe { 5 | return new Maybe(value) 6 | } 7 | 8 | export function none(): IMaybe { 9 | return Maybe.none() 10 | } 11 | 12 | export function some(value: T): IMaybe { 13 | return maybe(value) 14 | } 15 | 16 | /** 17 | * Creates a function that returns a Maybe for the given property path. 18 | * 19 | * This is a powerful utility for safely navigating nested object structures. 20 | * It creates a type-safe property accessor function that returns a Maybe 21 | * containing the value at the specified path if it exists, or None if any 22 | * part of the path is missing. 23 | * 24 | * @param path A dot-separated string path to the desired property 25 | * @returns A function that takes an object and returns a Maybe of the property value 26 | * 27 | * @example 28 | * const getEmail = maybeProps('profile.contact.email'); 29 | * 30 | * // Later in code 31 | * const emailMaybe = getEmail(user); 32 | * // Returns Some(email) if user.profile.contact.email exists 33 | * // Returns None if any part of the path is undefined/null 34 | * 35 | * // Use with filter 36 | * const validEmail = getEmail(user).filter(email => email.includes('@')); 37 | * 38 | * // Use with match 39 | * getEmail(user).match({ 40 | * some: email => sendVerification(email), 41 | * none: () => showEmailPrompt() 42 | * }); 43 | */ 44 | export function maybeProps(path: string): (obj: T) => IMaybe { 45 | const segments = path.split('.') 46 | 47 | return (obj: T): IMaybe => { 48 | let current = obj as unknown 49 | 50 | for (const segment of segments) { 51 | if (current === null || current === undefined || !(segment in (current as Record))) { 52 | return none() 53 | } 54 | current = (current as Record)[segment] 55 | } 56 | 57 | return maybe(current as R) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/logger/logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger' 2 | 3 | describe(Logger.name, () => { 4 | 5 | it('should construct', () => { 6 | const sut = new Logger([], '') 7 | 8 | expect(sut).toBeInstanceOf(Logger) 9 | }) 10 | 11 | it('should flatMap', () => { 12 | const sut = new Logger(['starting with 100'], 100) 13 | .flatMap(b => new Logger(['adding 3'], b + 3)) 14 | 15 | expect(sut).toBeInstanceOf(Logger) 16 | }) 17 | 18 | it('should tell', () => { 19 | Logger 20 | .tell('starting Logger') 21 | .flatMap(b => new Logger([`adding 3 to ${b}`], b + 3)) 22 | .flatMap(b => new Logger([`adding 3 to ${b}`], b + 3)) 23 | .runUsing(a => { 24 | expect(a.logs).toEqual([ 25 | 'starting Logger', 26 | 'adding 3 to 0', 27 | 'adding 3 to 3' 28 | ]) 29 | expect(a.value).toEqual(6) 30 | }) 31 | }) 32 | 33 | it('should tell', () => { 34 | Logger 35 | .tell('starting Logger') 36 | .of('ofed') 37 | .runUsing(a => { 38 | expect(a.logs).toEqual([]) 39 | expect(a.value).toEqual('ofed') 40 | }) 41 | }) 42 | 43 | it('should construt static', () => { 44 | const sut = Logger.logger(['starting with 100'], 100) 45 | .flatMap(b => new Logger(['adding 3'], b + 3)) 46 | 47 | expect(sut).toBeInstanceOf(Logger) 48 | }) 49 | 50 | it('should todo...', () => { 51 | Logger 52 | .startWith('Starting calculation with value: 100', 100) 53 | .flatMap(b => new Logger([`adding 3 to ${b}`], b + 3)) 54 | .runUsing(a => { 55 | expect(a.logs).toEqual([ 56 | 'Starting calculation with value: 100', 57 | 'adding 3 to 100' 58 | ]) 59 | expect(a.value).toEqual(103) 60 | }) 61 | }) 62 | 63 | it('should todo...', () => { 64 | Logger 65 | .startWith('Starting calculation with value: 100', 100) 66 | .flatMapPair(b => [`adding 3 to ${b}`, b + 3]) 67 | .runUsing(a => { 68 | expect(a.logs).toEqual([ 69 | 'Starting calculation with value: 100', 70 | 'adding 3 to 100' 71 | ]) 72 | expect(a.value).toEqual(103) 73 | }) 74 | }) 75 | 76 | }) 77 | -------------------------------------------------------------------------------- /src/maybe/transformers/maybe-to-promise.ts: -------------------------------------------------------------------------------- 1 | import { IMaybe } from '../maybe.interface' 2 | 3 | /** 4 | * Convert a Maybe to a Promise 5 | * 6 | * By default: 7 | * - Some values are converted to resolved promises with the contained value 8 | * - None values are converted to rejected promises with an optional rejection value 9 | * 10 | * When handleNoneAsResolved is true: 11 | * - None values are converted to resolved promises with the fallback value 12 | * 13 | * Note on resolution loss: This transformation loses the Maybe context. 14 | * Once converted to a Promise, you can no longer distinguish between a 15 | * None and a Some directly; you must handle this through promise rejection 16 | * or by examining the resolved value. 17 | * 18 | * @param catchResponse Optional value to use when rejecting the promise for None values 19 | * @param handleNoneAsResolved Optional flag to handle None as a resolved promise with fallbackValue 20 | * @param fallbackValue Optional value to resolve with when None is encountered and handleNoneAsResolved is true 21 | * @returns A function that converts a Maybe to a Promise 22 | * 23 | * @example 24 | * // Converting None to a rejected promise 25 | * maybe(user) 26 | * .flatMap(u => maybe(u.profile)) 27 | * .then(maybeToPromise(new Error('Profile not found'))) 28 | * .then(profile => console.log(profile.name)) 29 | * .catch(err => console.error(err.message)) // 'Profile not found' 30 | * 31 | * // Converting None to a resolved promise with a default value 32 | * maybe(user) 33 | * .flatMap(u => maybe(u.profile)) 34 | * .then(maybeToPromise(null, true, { name: 'Anonymous' })) 35 | * .then(profile => console.log(profile.name)) // 'Anonymous' when profile is None 36 | */ 37 | export function maybeToPromise( 38 | catchResponse?: TReject, 39 | handleNoneAsResolved = false, 40 | fallbackValue?: TResolve 41 | ) { 42 | return function maybeToPromiseConverter(maybe: IMaybe): Promise { 43 | if (maybe.isSome()) { 44 | return Promise.resolve(maybe.valueOrThrow()) 45 | } 46 | 47 | return handleNoneAsResolved 48 | ? Promise.resolve(fallbackValue as TResolve) 49 | : Promise.reject(catchResponse) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/maybe/transformers/try-promise-to-maybe.spec.ts: -------------------------------------------------------------------------------- 1 | import { tryPromiseToMaybe } from './try-promise-to-maybe' 2 | 3 | describe('tryPromiseToMaybe', () => { 4 | it('should create a Some from a successful function', async (): Promise => { 5 | const fn = async (): Promise => 'test' 6 | const result = await tryPromiseToMaybe(fn) 7 | 8 | expect(result.isSome()).toBe(true) 9 | expect(result.valueOr('default' as never)).toBe('test') 10 | }) 11 | 12 | it('should create a None from a function that throws', async (): Promise => { 13 | const fn = async (): Promise => { 14 | throw new Error('error') 15 | } 16 | const result = await tryPromiseToMaybe(fn) 17 | 18 | expect(result.isNone()).toBe(true) 19 | expect(result.valueOr('default' as never)).toBe('default') 20 | }) 21 | 22 | it('should create a None from a function that resolves to null', async (): Promise => { 23 | const fn = async (): Promise => null 24 | const result = await tryPromiseToMaybe(fn) 25 | 26 | expect(result.isNone()).toBe(true) 27 | }) 28 | 29 | it('should create a None from a function that resolves to undefined', async (): Promise => { 30 | const fn = async (): Promise => undefined 31 | const result = await tryPromiseToMaybe(fn) 32 | 33 | expect(result.isNone()).toBe(true) 34 | }) 35 | 36 | it('should handle API-like scenarios', async (): Promise => { 37 | interface User { name: string } 38 | 39 | // Mock API 40 | const successApi = { 41 | fetchUser: async (id: number): Promise => { 42 | return { name: `User ${id}` } 43 | } 44 | } 45 | 46 | const failingApi = { 47 | fetchUser: async (id: number): Promise => { 48 | throw new Error(`Network error for ID ${id}`) 49 | } 50 | } 51 | 52 | const successResult = await tryPromiseToMaybe(() => successApi.fetchUser(1)) 53 | expect(successResult.isSome()).toBe(true) 54 | expect(successResult.valueOr({ name: 'default' } as never).name).toBe('User 1') 55 | 56 | const failureResult = await tryPromiseToMaybe(() => failingApi.fetchUser(1)) 57 | expect(failureResult.isNone()).toBe(true) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/result/transformers/observable-to-result.spec.ts: -------------------------------------------------------------------------------- 1 | import { observableToResult } from './observable-to-result' 2 | import { Subject, of, throwError, EMPTY } from 'rxjs' 3 | 4 | describe('observableToResult', () => { 5 | it('should convert an observable that emits a value to an Ok Result', async () => { 6 | // Arrange 7 | const value = { id: 1, name: 'Test' } 8 | const observable = of(value) 9 | const defaultError = new Error('No value emitted') 10 | 11 | // Act 12 | const result = await observableToResult(observable, defaultError) 13 | 14 | // Assert 15 | expect(result.isOk()).toBe(true) 16 | expect(result.unwrap()).toEqual(value) 17 | }) 18 | 19 | it('should convert an observable that errors to a Fail Result', async () => { 20 | // Arrange 21 | const error = new Error('Observable error') 22 | const observable = throwError(() => error) 23 | const defaultError = new Error('Default error') 24 | 25 | // Act 26 | const result = await observableToResult(observable, defaultError) 27 | 28 | // Assert 29 | expect(result.isFail()).toBe(true) 30 | expect(result.unwrapFail()).toBe(error) 31 | }) 32 | 33 | it('should convert an empty observable to a Fail Result with default error', async () => { 34 | // Arrange 35 | const observable = EMPTY 36 | const defaultError = new Error('No value emitted') 37 | 38 | // Act 39 | const result = await observableToResult(observable, defaultError) 40 | 41 | // Assert 42 | expect(result.isFail()).toBe(true) 43 | expect(result.unwrapFail()).toBe(defaultError) 44 | }) 45 | 46 | it('should take only the first emitted value from the observable', async () => { 47 | // Arrange 48 | const subject = new Subject() 49 | const defaultError = new Error('No value emitted') 50 | 51 | // Start the async operation 52 | const resultPromise = observableToResult(subject, defaultError) 53 | 54 | // Emit multiple values 55 | subject.next(1) 56 | subject.next(2) 57 | subject.next(3) 58 | subject.complete() 59 | 60 | // Act 61 | const result = await resultPromise 62 | 63 | // Assert 64 | expect(result.isOk()).toBe(true) 65 | expect(result.unwrap()).toBe(1) // Only the first value should be captured 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/result/transformers/try-promise-to-result.ts: -------------------------------------------------------------------------------- 1 | import { ok, fail } from '../result.factory' 2 | import { IResult } from '../result.interface' 3 | 4 | /** 5 | * Converts a Promise to a Result monad with a custom error mapping function 6 | * 7 | * Creates a Result from a Promise with control over error handling: 8 | * - If the promise resolves, returns an Ok Result with the resolved value 9 | * - If the promise rejects, maps the error using the provided function and returns a Fail Result 10 | * 11 | * This function provides more control over error handling than promiseToResult by allowing 12 | * you to transform the rejection reason (typically Error objects) to a domain-specific 13 | * error type of your choosing. 14 | * 15 | * @param promise The promise to convert to a Result 16 | * @param errorMapper A function to transform the rejection reason to your error type 17 | * @returns A Promise that resolves to a Result containing either the resolved value or mapped error 18 | * 19 | * @example 20 | * // Define a domain-specific error type 21 | * interface ApiError { 22 | * code: string; 23 | * message: string; 24 | * timestamp: Date; 25 | * } 26 | * 27 | * // Map raw errors to your domain-specific format 28 | * const mapError = (error: unknown): ApiError => { 29 | * if (error instanceof Error) { 30 | * return { 31 | * code: 'ERR_API', 32 | * message: error.message, 33 | * timestamp: new Date() 34 | * }; 35 | * } 36 | * return { 37 | * code: 'ERR_UNKNOWN', 38 | * message: String(error), 39 | * timestamp: new Date() 40 | * }; 41 | * }; 42 | * 43 | * // Convert the promise with your error mapper 44 | * tryPromiseToResult(fetchData(), mapError) 45 | * .then(result => { 46 | * // Use the result with your specific error type 47 | * return result.match({ 48 | * ok: data => renderData(data), 49 | * fail: (error: ApiError) => { 50 | * logError(error.code, error.message); 51 | * showErrorWithCode(error.code, error.message); 52 | * } 53 | * }); 54 | * }); 55 | */ 56 | export function tryPromiseToResult( 57 | promise: Promise, 58 | errorMapper: (error: unknown) => E 59 | ): Promise> { 60 | return promise 61 | .then((value: T) => ok(value)) 62 | .catch((error: unknown) => fail(errorMapper(error))) 63 | } 64 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [ 3 | "dist", 4 | "vscode", 5 | "node_modules", 6 | "coverage", 7 | "ormconfig.js", 8 | "jest.config.js", 9 | "jest.setup.js", 10 | "rollup.config.js", 11 | "scripts" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": "tsconfig.json", 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "@typescript-eslint/eslint-plugin", 20 | "promise", 21 | "rxjs" 22 | ], 23 | "extends": [ 24 | "plugin:@typescript-eslint/eslint-recommended", 25 | "plugin:@typescript-eslint/recommended" 26 | ], 27 | "env": { 28 | "node": true, 29 | "jest": true 30 | }, 31 | "rules": { 32 | "no-irregular-whitespace": 2, 33 | "array-bracket-spacing": [ 34 | 2, 35 | "never" 36 | ], 37 | "space-in-parens": [ 38 | 2, 39 | "never" 40 | ], 41 | "promise/no-nesting": 0, 42 | "semi": [ 43 | 2, 44 | "never" 45 | ], 46 | "@typescript-eslint/indent": [ 47 | "error", 48 | 2 49 | ], 50 | "@typescript-eslint/comma-spacing": [ 51 | "error", 52 | { 53 | "before": false, 54 | "after": true 55 | } 56 | ], 57 | "@typescript-eslint/quotes": [ 58 | "error", 59 | "single" 60 | ], 61 | "comma-dangle": 2, 62 | "no-unexpected-multiline": 2, 63 | "@typescript-eslint/interface-name-prefix": 0, 64 | "@typescript-eslint/no-explicit-any": 2, 65 | "@typescript-eslint/no-empty-function": 0, 66 | "@typescript-eslint/no-var-requires": 0, 67 | "@typescript-eslint/explicit-function-return-type": 2, 68 | "@typescript-eslint/no-unused-vars": [ 69 | "error", 70 | { 71 | "args": "after-used" 72 | } 73 | ], 74 | "no-multiple-empty-lines": [ 75 | "error", 76 | { 77 | "max": 1, 78 | "maxEOF": 0 79 | } 80 | ], 81 | "eol-last": [ 82 | "error", 83 | "always" 84 | ], 85 | "@typescript-eslint/member-delimiter-style": [ 86 | 2, 87 | { 88 | "multiline": { 89 | "delimiter": "none", 90 | "requireLast": true 91 | } 92 | } 93 | ], 94 | "rxjs/no-nested-subscribe": 2, 95 | "rxjs/no-index": 2, 96 | "rxjs/no-internal": 2, 97 | "rxjs/no-unsafe-takeuntil": 2, 98 | "rxjs/no-unsafe-first": 2, 99 | "rxjs/no-unsafe-catch": 2 100 | } 101 | } -------------------------------------------------------------------------------- /src/result/transformers/observable-to-result.ts: -------------------------------------------------------------------------------- 1 | import { ok, fail } from '../result.factory' 2 | import { IResult } from '../result.interface' 3 | import { Observable, firstValueFrom } from 'rxjs' 4 | import { catchError, take, map } from 'rxjs/operators' 5 | 6 | /** 7 | * Converts an Observable to a Result monad 8 | * 9 | * Creates a Result from the first value emitted by an Observable: 10 | * - If the observable emits a value, returns an Ok Result with that value 11 | * - If the observable completes without emitting or errors, returns a Fail Result with the provided error 12 | * - If the observable errors, returns a Fail Result with the error 13 | * 14 | * This function transforms the resolution semantics of an Observable to the Result context: 15 | * - Observable emissions (data) become Ok values (success) 16 | * - Observable completion without emissions (empty success) becomes Fail (failure with default error) 17 | * - Observable errors (failure) become Fail (failure with error) 18 | * 19 | * Note that the timing model changes from a continuous/reactive model to a one-time 20 | * asynchronous result. Only the first emission is captured, and the observable is 21 | * no longer reactive after transformation. 22 | * 23 | * @param observable The observable to convert to a Result 24 | * @param defaultError The error to use if the observable completes without emitting a value 25 | * @returns A Promise that resolves to a Result containing either the emitted value or an error 26 | * 27 | * @requires rxjs@^7.0 28 | * @example 29 | * // Convert an observable to a Result 30 | * userService.getUser(userId) 31 | * .pipe(take(1)) 32 | * .toPromise() 33 | * .then(promiseToResult) 34 | * .then(result => result.match({ 35 | * ok: user => renderUser(user), 36 | * fail: error => showUserNotFound(error) 37 | * })); 38 | * 39 | * // Using observableToResult directly 40 | * observableToResult( 41 | * userService.getUser(userId), 42 | * new Error('User not found') 43 | * ).then(result => { 44 | * if (result.isOk()) { 45 | * return renderUser(result.unwrap()); 46 | * } else { 47 | * return showError(result.unwrapFail()); 48 | * } 49 | * }); 50 | */ 51 | export function observableToResult( 52 | observable: Observable, 53 | defaultError: E 54 | ): Promise> { 55 | return firstValueFrom( 56 | observable.pipe( 57 | take(1), 58 | map(value => ok(value)), 59 | catchError((error: unknown) => { 60 | // Return the error from the observable directly rather than using EMPTY 61 | return [fail(error as E)] 62 | }) 63 | ) 64 | ).then( 65 | (result: IResult) => result, 66 | () => fail(defaultError) // Handle the case where firstValueFrom rejects 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/maybe/maybe.factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { maybe, none, some, maybeProps } from './maybe.factory' 2 | 3 | describe('should construct maybes', () => { 4 | it('should handle "maybe" case', () => { 5 | const sut = 'asdasd' as string | undefined 6 | expect(maybe(sut).isSome()).toEqual(true) 7 | }) 8 | 9 | it('should handle "none" case', () => { 10 | expect(none().isNone()).toEqual(true) 11 | }) 12 | 13 | it('should handle "some" case', () => { 14 | expect(some('test').isSome()).toEqual(true) 15 | }) 16 | }) 17 | 18 | describe('maybeProps', () => { 19 | it('should return Some for valid property paths', () => { 20 | const user = { 21 | profile: { 22 | contact: { 23 | email: 'test@example.com' 24 | } 25 | } 26 | } 27 | 28 | const getEmail = maybeProps('profile.contact.email') 29 | const result = getEmail(user) 30 | 31 | expect(result.isSome()).toEqual(true) 32 | expect(result.valueOr('default')).toEqual('test@example.com') 33 | }) 34 | 35 | it('should return None for invalid property paths', () => { 36 | const user = { 37 | profile: { 38 | contact: { 39 | // No email property 40 | } 41 | } 42 | } 43 | 44 | const getEmail = maybeProps('profile.contact.email') 45 | const result = getEmail(user) 46 | 47 | expect(result.isNone()).toEqual(true) 48 | }) 49 | 50 | it('should return None for null intermediate values', () => { 51 | const user = { 52 | profile: null 53 | } 54 | 55 | const getEmail = maybeProps('profile.contact.email') 56 | const result = getEmail(user) 57 | 58 | expect(result.isNone()).toEqual(true) 59 | }) 60 | 61 | it('should handle arrays in paths', () => { 62 | const data = { 63 | users: [ 64 | { name: 'User 1' }, 65 | { name: 'User 2' } 66 | ] 67 | } 68 | 69 | const getFirstUserName = maybeProps('users.0.name') 70 | const result = getFirstUserName(data) 71 | 72 | expect(result.isSome()).toEqual(true) 73 | expect(result.valueOr('default')).toEqual('User 1') 74 | }) 75 | 76 | it('should return None for out-of-bounds array indices', () => { 77 | const data = { 78 | users: [ 79 | { name: 'User 1' } 80 | ] 81 | } 82 | 83 | const getNonExistentUser = maybeProps('users.5.name') 84 | const result = getNonExistentUser(data) 85 | 86 | expect(result.isNone()).toEqual(true) 87 | }) 88 | 89 | it('should work with single-segment paths', () => { 90 | const data = { 91 | name: 'Test' 92 | } 93 | 94 | const getName = maybeProps('name') 95 | const result = getName(data) 96 | 97 | expect(result.isSome()).toEqual(true) 98 | expect(result.valueOr('default')).toEqual('Test') 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/result/transformers/try-promise-to-result.spec.ts: -------------------------------------------------------------------------------- 1 | import { tryPromiseToResult } from './try-promise-to-result' 2 | 3 | describe('tryPromiseToResult', () => { 4 | it('should convert a resolved promise to an Ok Result', async (): Promise => { 5 | // Arrange 6 | const value = { success: true, data: 'test' } 7 | const promise = Promise.resolve(value) 8 | const errorMapper = (err: unknown): { code: string; message: string } => ({ code: 'ERROR', message: String(err) }) 9 | 10 | // Act 11 | const result = await tryPromiseToResult(promise, errorMapper) 12 | 13 | // Assert 14 | expect(result.isOk()).toBe(true) 15 | expect(result.unwrap()).toEqual(value) 16 | }) 17 | 18 | it('should convert a rejected promise to a Fail Result with mapped error', async (): Promise => { 19 | // Arrange 20 | const error = new Error('Test error') 21 | const promise = Promise.reject(error) 22 | const errorMapper = (err: unknown): { code: string; message: string } => ({ 23 | code: 'ERR_TEST', 24 | message: err instanceof Error ? err.message : String(err) 25 | }) 26 | 27 | // Act 28 | const result = await tryPromiseToResult(promise, errorMapper) 29 | 30 | // Assert 31 | expect(result.isFail()).toBe(true) 32 | expect(result.unwrapFail()).toEqual({ 33 | code: 'ERR_TEST', 34 | message: 'Test error' 35 | }) 36 | }) 37 | 38 | it('should handle non-Error rejections', async (): Promise => { 39 | // Arrange 40 | const promise = Promise.reject('Simple string error') 41 | const errorMapper = (err: unknown): { code: string; message: string } => ({ 42 | code: 'ERR_SIMPLE', 43 | message: String(err) 44 | }) 45 | 46 | // Act 47 | const result = await tryPromiseToResult(promise, errorMapper) 48 | 49 | // Assert 50 | expect(result.isFail()).toBe(true) 51 | expect(result.unwrapFail()).toEqual({ 52 | code: 'ERR_SIMPLE', 53 | message: 'Simple string error' 54 | }) 55 | }) 56 | 57 | it('should use a custom error mapper to transform errors', async (): Promise => { 58 | // Arrange 59 | const error = new Error('Permission denied') 60 | const promise = Promise.reject(error) 61 | const errorMapper = (err: unknown): { type: string; reason: string; originalError?: Error } => { 62 | if (err instanceof Error && err.message.includes('Permission')) { 63 | return { 64 | type: 'AuthError', 65 | reason: 'Insufficient privileges', 66 | originalError: err 67 | } 68 | } 69 | return { 70 | type: 'UnknownError', 71 | reason: String(err) 72 | } 73 | } 74 | 75 | // Act 76 | const result = await tryPromiseToResult(promise, errorMapper) 77 | 78 | // Assert 79 | expect(result.isFail()).toBe(true) 80 | expect(result.unwrapFail()).toEqual({ 81 | type: 'AuthError', 82 | reason: 'Insufficient privileges', 83 | originalError: error 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/maybe/transformers/promise-to-maybe.spec.ts: -------------------------------------------------------------------------------- 1 | import { promiseToMaybe } from './promise-to-maybe' 2 | import { maybe } from '../maybe.factory' 3 | import { Maybe } from '../maybe' 4 | 5 | describe('promiseToMaybe', () => { 6 | it('should create a Some from a resolved promise', (done) => { 7 | promiseToMaybe(Promise.resolve('test')) 8 | .then(result => { 9 | expect(result.isSome()).toBe(true) 10 | expect(result.valueOr('default')).toBe('test') 11 | done() 12 | }) 13 | }) 14 | 15 | it('should create a None from a rejected promise', (done) => { 16 | promiseToMaybe(Promise.reject(new Error('error'))) 17 | .then(result => { 18 | expect(result.isNone()).toBe(true) 19 | done() 20 | }) 21 | }) 22 | 23 | it('should create a None from a promise that resolves to null', (done) => { 24 | promiseToMaybe(Promise.resolve(null)) 25 | .then(result => { 26 | expect(result.isNone()).toBe(true) 27 | done() 28 | }) 29 | }) 30 | 31 | it('should create a None from a promise that resolves to undefined', (done) => { 32 | promiseToMaybe(Promise.resolve(undefined)) 33 | .then(result => { 34 | expect(result.isNone()).toBe(true) 35 | done() 36 | }) 37 | }) 38 | 39 | it('should work with Maybe.flatMapPromise', (done) => { 40 | interface User { name: string } 41 | 42 | const fetchUser = (id: number): Promise => Promise.resolve(id === 1 ? { name: 'User' } : null) 43 | maybe(1).flatMapPromise(fetchUser) 44 | .then(result => { 45 | expect(result.isSome()).toBe(true) 46 | 47 | // Type assertion to help TypeScript 48 | const user = result.valueOr({ name: 'default' } as User) 49 | expect(user.name).toBe('User') 50 | done() 51 | }) 52 | }) 53 | 54 | it('should create None from Maybe.flatMapPromise when promise rejects', (done) => { 55 | const fetchUser = (): Promise => Promise.reject(new Error('error')) 56 | maybe(1).flatMapPromise(fetchUser) 57 | .then(result => { 58 | expect(result.isNone()).toBe(true) 59 | done() 60 | }) 61 | }) 62 | 63 | it('should create None from Maybe.flatMapPromise when initial Maybe is None', (done) => { 64 | const fetchUser = (): Promise<{name: string}> => Promise.resolve({ name: 'User' }) 65 | maybe().flatMapPromise(fetchUser) 66 | .then(result => { 67 | expect(result.isNone()).toBe(true) 68 | done() 69 | }) 70 | }) 71 | 72 | it('should work with Maybe.fromPromise', (done) => { 73 | interface User { name: string } 74 | 75 | const fetchUser = (): Promise => Promise.resolve({ name: 'User' }) 76 | Maybe.fromPromise(fetchUser()) 77 | .then(result => { 78 | expect(result.isSome()).toBe(true) 79 | 80 | // Type assertion to help TypeScript 81 | const user = result.valueOr({ name: 'default' } as User) 82 | expect(user.name).toBe('User') 83 | done() 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | .DS_Store 8 | dist 9 | .env 10 | .idea 11 | documentation 12 | junit.xml 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | dist/ 67 | 68 | .DS_Store 69 | 70 | test-report.xml 71 | test-results.xml 72 | 73 | ngc 74 | .ngc 75 | .aot 76 | aot 77 | .e2e 78 | 79 | # Created by https://www.gitignore.io/api/webstorm 80 | 81 | ### WebStorm ### 82 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 83 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 84 | 85 | # User-specific stuff: 86 | .idea/codeStyleSettings.xml 87 | .idea/**/workspace.xml 88 | .idea/**/tasks.xml 89 | .idea/dictionaries 90 | 91 | # Sensitive or high-churn files: 92 | .idea/**/dataSources/ 93 | .idea/**/dataSources.ids 94 | .idea/**/dataSources.xml 95 | .idea/**/dataSources.local.xml 96 | .idea/**/sqlDataSources.xml 97 | .idea/**/dynamic.xml 98 | .idea/**/uiDesigner.xml 99 | 100 | # Gradle: 101 | .idea/**/gradle.xml 102 | .idea/**/libraries 103 | 104 | # CMake 105 | cmake-build-debug/ 106 | 107 | # Mongo Explorer plugin: 108 | .idea/**/mongoSettings.xml 109 | 110 | ## File-based project format: 111 | *.iws 112 | 113 | ## Plugin-specific files: 114 | 115 | # IntelliJ 116 | /out/ 117 | 118 | # mpeltonen/sbt-idea plugin 119 | .idea_modules/ 120 | 121 | # JIRA plugin 122 | atlassian-ide-plugin.xml 123 | 124 | # Cursive Clojure plugin 125 | .idea/replstate.xml 126 | 127 | # Crashlytics plugin (for Android Studio and IntelliJ) 128 | com_crashlytics_export_strings.xml 129 | crashlytics.properties 130 | crashlytics-build.properties 131 | fabric.properties 132 | 133 | ### WebStorm Patch ### 134 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 135 | 136 | # *.iml 137 | # modules.xml 138 | # .idea/misc.xml 139 | # *.ipr 140 | 141 | # Sonarlint plugin 142 | .idea/sonarlint 143 | .tmp -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-monads", 3 | "version": "0.0.0-development", 4 | "description": "Write cleaner TypeScript", 5 | "main": "index.js", 6 | "module": "index.esm.js", 7 | "commonJs": "index.cjs.js", 8 | "typings": "index.d.ts", 9 | "sideEffects": false, 10 | "author": "Patrick Michalina (https://patrickmichalina.com)", 11 | "contributors": [ 12 | "Williama Reynolds" 13 | ], 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/patrickmichalina/typescript-monads" 18 | }, 19 | "keywords": [ 20 | "typescript", 21 | "javascript", 22 | "monads", 23 | "maybe", 24 | "result", 25 | "either", 26 | "list", 27 | "state", 28 | "functional", 29 | "list-monad", 30 | "maybe-monad", 31 | "either-monad", 32 | "result-monad", 33 | "state-monad" 34 | ], 35 | "scripts": { 36 | "test": "jest --maxWorkers=6", 37 | "test.watch": "jest --watch", 38 | "dist": "ts-node ./scripts/publish-prep.ts", 39 | "lint": "tsc --noEmit && eslint '{src,apps,libs,test,e2e,tools}/**/*.ts'", 40 | "lint.fix": "npm run lint -- --fix", 41 | "build": "tsc -p tsconfig.build.json && npm run rollup && terser dist/index.js -o dist/index.min.js --source-map", 42 | "rollup": "rollup -c rollup.config.js" 43 | }, 44 | "release": { 45 | "pkgRoot": "dist" 46 | }, 47 | "devDependencies": { 48 | "@rollup/plugin-typescript": "8.5.0", 49 | "@types/fs-extra": "11.0.4", 50 | "@types/jest": "29.5.14", 51 | "@types/node": "22.13.11", 52 | "@typescript-eslint/eslint-plugin": "^5.59.2", 53 | "@typescript-eslint/parser": "^5.59.2", 54 | "codecov": "3.8.3", 55 | "eslint": "8.57.1", 56 | "eslint-plugin-promise": "^6.1.1", 57 | "eslint-plugin-rxjs": "5.0.3", 58 | "fast-check": "4.0.0", 59 | "fs-extra": "11.3.0", 60 | "istanbul": "0.4.5", 61 | "jest": "29.7.0", 62 | "jest-junit": "16.0.0", 63 | "rollup": "2.79.2", 64 | "semantic-release": "21.0.2", 65 | "terser": "5.39.0", 66 | "ts-jest": "29.2.6", 67 | "ts-node": "10.9.2", 68 | "tslib": "2.8.1", 69 | "typescript": "5.8.2" 70 | }, 71 | "optionalDependencies": { 72 | "rxjs": "^7" 73 | }, 74 | "jest": { 75 | "testEnvironmentOptions": { 76 | "url": "http://localhost" 77 | }, 78 | "collectCoverage": true, 79 | "collectCoverageFrom": [ 80 | "src/**/*.ts" 81 | ], 82 | "coverageReporters": [ 83 | "lcov", 84 | "text" 85 | ], 86 | "coverageThreshold": { 87 | "global": { 88 | "branches": 100, 89 | "functions": 100, 90 | "lines": 100, 91 | "statements": 100 92 | } 93 | }, 94 | "transform": { 95 | "^.+\\.tsx?$": "ts-jest" 96 | }, 97 | "testPathIgnorePatterns": [ 98 | "/node_modules/", 99 | "/dist/", 100 | "public_api.ts" 101 | ], 102 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(ts?)$", 103 | "moduleFileExtensions": [ 104 | "ts", 105 | "tsx", 106 | "js", 107 | "jsx", 108 | "json", 109 | "node" 110 | ] 111 | } 112 | } -------------------------------------------------------------------------------- /src/maybe/transformers/observable-to-maybe.spec.ts: -------------------------------------------------------------------------------- 1 | import { observableToMaybe } from './observable-to-maybe' 2 | import { maybe } from '../maybe.factory' 3 | import { of, EMPTY, throwError } from 'rxjs' 4 | import { Maybe } from '../maybe' 5 | 6 | describe('observableToMaybe', () => { 7 | it('should create a Some from an observable that emits a value', (done) => { 8 | observableToMaybe(of('test')) 9 | .then(result => { 10 | expect(result.isSome()).toBe(true) 11 | expect(result.valueOr('default')).toBe('test') 12 | done() 13 | }) 14 | }) 15 | 16 | it('should create a None from an observable that completes without emitting', (done) => { 17 | observableToMaybe(EMPTY) 18 | .then(result => { 19 | expect(result.isNone()).toBe(true) 20 | done() 21 | }) 22 | }) 23 | 24 | it('should create a None from an observable that errors', (done) => { 25 | const errorObservable = throwError(() => new Error('error')) 26 | observableToMaybe(errorObservable) 27 | .then(result => { 28 | expect(result.isNone()).toBe(true) 29 | done() 30 | }) 31 | }) 32 | 33 | it('should create a None from an observable that emits null', (done) => { 34 | observableToMaybe(of(null)) 35 | .then(result => { 36 | expect(result.isNone()).toBe(true) 37 | done() 38 | }) 39 | }) 40 | 41 | it('should create a None from an observable that emits undefined', (done) => { 42 | observableToMaybe(of(undefined)) 43 | .then(result => { 44 | expect(result.isNone()).toBe(true) 45 | done() 46 | }) 47 | }) 48 | 49 | it('should work with Maybe.flatMapObservable', (done) => { 50 | interface User { name: string } 51 | 52 | const getUser = (id: number): import('rxjs').Observable => of(id === 1 ? { name: 'User' } : null) 53 | maybe(1).flatMapObservable(getUser) 54 | .then(result => { 55 | expect(result.isSome()).toBe(true) 56 | 57 | // Type assertion to help TypeScript 58 | const user = result.valueOr({ name: 'default' } as User) 59 | expect(user.name).toBe('User') 60 | done() 61 | }) 62 | }) 63 | 64 | it('should create None from Maybe.flatMapObservable when observable errors', (done) => { 65 | const getUser = (): import('rxjs').Observable => throwError(() => new Error('error')) 66 | maybe(1).flatMapObservable(getUser) 67 | .then(result => { 68 | expect(result.isNone()).toBe(true) 69 | done() 70 | }) 71 | }) 72 | 73 | it('should create None from Maybe.flatMapObservable when initial Maybe is None', (done) => { 74 | const getUser = (): import('rxjs').Observable<{name: string}> => of({ name: 'User' }) 75 | maybe().flatMapObservable(getUser) 76 | .then(result => { 77 | expect(result.isNone()).toBe(true) 78 | done() 79 | }) 80 | }) 81 | 82 | it('should work with Maybe.fromObservable', (done) => { 83 | interface User { name: string } 84 | 85 | const getUser = (): import('rxjs').Observable => of({ name: 'User' }) 86 | Maybe.fromObservable(getUser()) 87 | .then(result => { 88 | expect(result.isSome()).toBe(true) 89 | 90 | // Type assertion to help TypeScript 91 | const user = result.valueOr({ name: 'default' } as User) 92 | expect(user.name).toBe('User') 93 | done() 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/result/result.static.spec.ts: -------------------------------------------------------------------------------- 1 | import { ok, fail, Result } from './public_api' 2 | import { of, throwError, EMPTY } from 'rxjs' 3 | 4 | describe('Result static methods', () => { 5 | describe('fromPromise', () => { 6 | it('should convert a resolved promise to an Ok Result', async () => { 7 | // Arrange 8 | const value = { success: true, data: 'test' } 9 | const promise = Promise.resolve(value) 10 | 11 | // Act 12 | const result = await Result.fromPromise(promise) 13 | 14 | // Assert 15 | expect(result.isOk()).toBe(true) 16 | expect(result.unwrap()).toEqual(value) 17 | }) 18 | 19 | it('should convert a rejected promise to a Fail Result', async () => { 20 | // Arrange 21 | const error = new Error('Test error') 22 | const promise = Promise.reject(error) 23 | 24 | // Act 25 | const result = await Result.fromPromise(promise) 26 | 27 | // Assert 28 | expect(result.isFail()).toBe(true) 29 | expect(result.unwrapFail()).toBe(error) 30 | }) 31 | }) 32 | 33 | describe('fromObservable', () => { 34 | it('should convert an observable that emits a value to an Ok Result', async () => { 35 | // Arrange 36 | const value = { id: 1, name: 'Test' } 37 | const observable = of(value) 38 | const defaultError = new Error('No value emitted') 39 | 40 | // Act 41 | const result = await Result.fromObservable(observable, defaultError) 42 | 43 | // Assert 44 | expect(result.isOk()).toBe(true) 45 | expect(result.unwrap()).toEqual(value) 46 | }) 47 | 48 | it('should convert an observable that errors to a Fail Result', async () => { 49 | // Arrange 50 | const error = new Error('Observable error') 51 | const observable = throwError(() => error) 52 | const defaultError = new Error('Default error') 53 | 54 | // Act 55 | const result = await Result.fromObservable(observable, defaultError) 56 | 57 | // Assert 58 | expect(result.isFail()).toBe(true) 59 | expect(result.unwrapFail()).toBe(error) 60 | }) 61 | 62 | it('should convert an empty observable to a Fail Result with default error', async () => { 63 | // Arrange 64 | const observable = EMPTY 65 | const defaultError = new Error('No value emitted') 66 | 67 | // Act 68 | const result = await Result.fromObservable(observable, defaultError) 69 | 70 | // Assert 71 | expect(result.isFail()).toBe(true) 72 | expect(result.unwrapFail()).toBe(defaultError) 73 | }) 74 | }) 75 | 76 | describe('sequence', () => { 77 | it('should return Ok with empty array when given an empty array', () => { 78 | // Act 79 | const result = Result.sequence([]) 80 | 81 | // Assert 82 | expect(result.isOk()).toBe(true) 83 | expect(result.unwrap()).toEqual([]) 84 | }) 85 | 86 | it('should return Ok with array of values when all Results are Ok', () => { 87 | // Arrange 88 | const results = [ok(1), ok(2), ok(3)] 89 | 90 | // Act 91 | const result = Result.sequence(results) 92 | 93 | // Assert 94 | expect(result.isOk()).toBe(true) 95 | expect(result.unwrap()).toEqual([1, 2, 3]) 96 | }) 97 | 98 | it('should return Fail with first error when any Result is Fail', () => { 99 | // Arrange 100 | const error = new Error('Test error') 101 | const results = [ok(1), fail(error), ok(3)] 102 | 103 | // Act 104 | const result = Result.sequence(results) 105 | 106 | // Assert 107 | expect(result.isFail()).toBe(true) 108 | expect(result.unwrapFail()).toBe(error) 109 | }) 110 | }) 111 | 112 | describe('all', () => { 113 | it('should return Ok with empty array when given an empty array', () => { 114 | // Act 115 | const result = Result.all([]) 116 | 117 | // Assert 118 | expect(result.isOk()).toBe(true) 119 | expect(result.unwrap()).toEqual([]) 120 | }) 121 | 122 | it('should return Ok with array of values when all Results are Ok', () => { 123 | // Arrange 124 | const results = [ok(1), ok(2), ok(3)] 125 | 126 | // Act 127 | const result = Result.all(results) 128 | 129 | // Assert 130 | expect(result.isOk()).toBe(true) 131 | expect(result.unwrap()).toEqual([1, 2, 3]) 132 | }) 133 | 134 | it('should return Fail with first error when any Result is Fail', () => { 135 | // Arrange 136 | const error = new Error('Test error') 137 | const results = [ok(1), fail(error), ok(3)] 138 | 139 | // Act 140 | const result = Result.all(results) 141 | 142 | // Assert 143 | expect(result.isFail()).toBe(true) 144 | expect(result.unwrapFail()).toBe(error) 145 | }) 146 | }) 147 | }) 148 | -------------------------------------------------------------------------------- /src/either/either.spec.ts: -------------------------------------------------------------------------------- 1 | import { either } from './either.factory' 2 | 3 | describe(either.name, () => { 4 | it('when calling should throw if both sides are defined', () => { 5 | expect(() => { 6 | const leftInput = 'The String' 7 | const rightInput = 'The String' 8 | either(leftInput, rightInput) 9 | }).toThrowError(TypeError('Either cannot have both a left and a right')) 10 | }) 11 | it('when calling should throw if no sides are defined', () => { 12 | expect(() => { 13 | const leftInput = undefined 14 | const rightInput = undefined 15 | either(leftInput, rightInput) 16 | }).toThrowError(TypeError('Either requires a left or a right')) 17 | }) 18 | it('should be left', () => { 19 | const leftInput = 'tester' 20 | const rightInput = undefined 21 | 22 | const eitherThing = either(leftInput, rightInput) 23 | 24 | expect(eitherThing.isLeft()).toBeTruthy() 25 | expect(eitherThing.isRight()).toBeFalsy() 26 | }) 27 | it('should be right', () => { 28 | const leftInput = undefined 29 | const rightInput = 'tester' 30 | 31 | const eitherThing = either(leftInput, rightInput) 32 | 33 | expect(eitherThing.isRight()).toBeTruthy() 34 | expect(eitherThing.isLeft()).toBeFalsy() 35 | }) 36 | 37 | it('should map to match right side', () => { 38 | const leftInput: number | undefined = undefined 39 | const rightInput = 'tester' 40 | 41 | const eitherThing = either(leftInput, rightInput) 42 | 43 | const mapped = eitherThing.match({ 44 | left: () => '123', 45 | right: str => `${str}_right` 46 | }) 47 | 48 | expect(mapped).toEqual('tester_right') 49 | }) 50 | 51 | it('should map to match left side', () => { 52 | const leftInput = 123 53 | const rightInput: string | undefined = undefined 54 | 55 | const eitherThing = either(leftInput, rightInput) 56 | 57 | const mapped = eitherThing.match({ 58 | left: num => `${num}_left`, 59 | right: str => `${str}_right` 60 | }) 61 | 62 | expect(mapped).toEqual('123_left') 63 | }) 64 | 65 | it('should map right biased', () => { 66 | const input1 = 123 67 | const input2 = undefined as number | undefined 68 | 69 | const eitherThing = either(input2, input1) 70 | const eitherThing2 = either(input1, input2) 71 | 72 | const mapped = eitherThing 73 | .map(rightNum => rightNum + 12) 74 | .match({ 75 | left: () => 3, 76 | right: num => num 77 | }) 78 | 79 | expect(mapped).toEqual(135) 80 | 81 | const mapped2 = eitherThing2 82 | .map(rightNum => rightNum + 12) 83 | .match({ 84 | left: () => 3, 85 | right: num => num 86 | }) 87 | 88 | expect(mapped2).toEqual(3) 89 | }) 90 | 91 | it('should flatMap', () => { 92 | const input1 = 123 93 | const input2 = undefined as number | undefined 94 | 95 | const eitherThing = either(input2, input1) 96 | 97 | const mapped = eitherThing 98 | .flatMap(rightNum => either(rightNum, input2)) 99 | .match({ 100 | left: () => 3, 101 | right: num => num 102 | }) 103 | 104 | expect(mapped).toEqual(3) 105 | }) 106 | 107 | it('should flatMap left', () => { 108 | const input1 = 123 109 | const input2 = undefined as number | undefined 110 | 111 | const eitherThing = either(input1, input2) 112 | 113 | const mapped = eitherThing 114 | .flatMap(rightNum => either(rightNum, input2)) 115 | .match({ 116 | left: () => 3, 117 | right: num => num 118 | }) 119 | 120 | expect(mapped).toEqual(3) 121 | }) 122 | 123 | it('should tap left', () => { 124 | expect.assertions(6) 125 | 126 | const input1 = 123 127 | const input2 = undefined 128 | 129 | const eitherThing = either(input1, input2) 130 | 131 | const mapped1 = eitherThing 132 | .tap({ 133 | right: () => fail(), 134 | left: leftSideEffect => { 135 | expect(leftSideEffect).toEqual(123) 136 | } 137 | }) 138 | 139 | const mapped2 = eitherThing 140 | .tap({ 141 | left: leftSideEffect => expect(leftSideEffect).toEqual(123) 142 | }) 143 | const mapped3 = eitherThing 144 | .tap({ 145 | right: () => fail() 146 | }) 147 | 148 | const mapped4 = eitherThing.tap({}) 149 | 150 | expect(mapped1).toEqual(undefined) 151 | expect(mapped2).toEqual(undefined) 152 | expect(mapped3).toEqual(undefined) 153 | expect(mapped4).toEqual(undefined) 154 | }) 155 | 156 | it('should tap right', () => { 157 | expect.assertions(6) 158 | 159 | const input1 = undefined 160 | const input2: number | undefined = 123 161 | 162 | const eitherThing = either(input1, input2) 163 | 164 | const mapped1 = eitherThing 165 | .tap({ 166 | left: () => fail(), 167 | right: rightSideEffect => expect(rightSideEffect).toEqual(123) 168 | }) 169 | 170 | const mapped2 = eitherThing 171 | .tap({ 172 | left: () => fail() 173 | }) 174 | const mapped3 = eitherThing 175 | .tap({ 176 | right: rightSideEffect => expect(rightSideEffect).toEqual(123) 177 | }) 178 | const mapped4 = eitherThing.tap({}) 179 | 180 | expect(mapped1).toEqual(undefined) 181 | expect(mapped2).toEqual(undefined) 182 | expect(mapped3).toEqual(undefined) 183 | expect(mapped4).toEqual(undefined) 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /src/reader/reader.factory.ts: -------------------------------------------------------------------------------- 1 | import { Reader } from './reader' 2 | import { IReader } from './reader.interface' 3 | 4 | /** 5 | * Creates a Reader monad with the given function. 6 | * 7 | * @typeParam TConfig - The environment/configuration type 8 | * @typeParam TOut - The result type 9 | * @param fn - Function that takes a configuration and returns a value 10 | * @returns A Reader containing the provided function 11 | * 12 | * @example 13 | * // Create a Reader that greets a user 14 | * const greet = reader<{name: string}, string>(config => `Hello, ${config.name}!`); 15 | * 16 | * // Run it with a configuration 17 | * const greeting = greet.run({name: 'Alice'}); // Returns 'Hello, Alice!' 18 | */ 19 | export function reader(fn: (config: TConfig) => TOut): IReader { 20 | return new Reader(fn) 21 | } 22 | 23 | /** 24 | * Creates a Reader that always returns a constant value, ignoring the environment. 25 | * 26 | * @typeParam TConfig - The environment/configuration type 27 | * @typeParam TOut - The result type 28 | * @param value - The constant value to return 29 | * @returns A Reader that always produces the given value 30 | * 31 | * @example 32 | * // Create a Reader that always returns 42 33 | * const constReader = readerOf(42); 34 | * constReader.run(anyEnvironment) // Returns 42 35 | */ 36 | export function readerOf(value: TOut): IReader { 37 | return Reader.of(value) 38 | } 39 | 40 | /** 41 | * Creates a Reader that returns the environment itself. 42 | * 43 | * @typeParam TConfig - The environment/configuration type 44 | * @returns A Reader that returns its environment 45 | * 46 | * @example 47 | * // Create a Reader that provides access to its environment 48 | * const askReader = ask(); 49 | * 50 | * // Use it to extract a specific part of the environment 51 | * const getApiUrl = askReader.map(config => config.apiUrl); 52 | */ 53 | export function ask(): IReader { 54 | return Reader.ask() 55 | } 56 | 57 | /** 58 | * Creates a Reader that accesses a specific part of the environment. 59 | * 60 | * @typeParam TConfig - The environment type 61 | * @typeParam TOut - The type of the property to access 62 | * @param accessor - Function that extracts a value from the environment 63 | * @returns A Reader that returns the specified part of the environment 64 | * 65 | * @example 66 | * // Create a Reader that accesses the apiUrl from a config object 67 | * const getApiUrl = asks(config => config.apiUrl); 68 | * 69 | * // Run it with a configuration 70 | * const url = getApiUrl.run({ apiUrl: 'https://api.example.com', timeout: 5000 }); 71 | * // url is 'https://api.example.com' 72 | */ 73 | export function asks(accessor: (config: TConfig) => TOut): IReader { 74 | return Reader.asks(accessor) 75 | } 76 | 77 | /** 78 | * Combines multiple Readers into a single Reader that returns an array of results. 79 | * 80 | * @typeParam TConfig - The shared environment type 81 | * @typeParam TOut - The type of each Reader's output 82 | * @param readers - Array of Readers to combine 83 | * @returns A Reader that produces an array of all results 84 | * 85 | * @example 86 | * // Define individual Readers 87 | * const getName = asks(c => c.name); 88 | * const getAge = asks(c => c.age); 89 | * const getEmail = asks(c => c.email); 90 | * 91 | * // Combine them to get all user info at once 92 | * const getUserInfo = sequence([getName, getAge, getEmail]); 93 | * 94 | * // Run with a configuration 95 | * const userInfo = getUserInfo.run({ 96 | * name: 'Alice', 97 | * age: 30, 98 | * email: 'alice@example.com' 99 | * }); 100 | * // userInfo is ['Alice', 30, 'alice@example.com'] 101 | */ 102 | export function sequence(readers: Array>): IReader { 103 | return Reader.sequence(readers) 104 | } 105 | 106 | /** 107 | * Combines multiple Readers into a single Reader, aggregating their results with a reducer function. 108 | * 109 | * @typeParam TConfig - The shared environment type 110 | * @typeParam TOut - The type of each Reader's output 111 | * @typeParam TAcc - The type of the accumulated result 112 | * @param readers - Array of Readers to combine 113 | * @param reducer - Function that combines the results 114 | * @param initialValue - Initial value for the accumulator 115 | * @returns A Reader that produces the aggregated result 116 | * 117 | * @example 118 | * // Define Readers for different stats 119 | * const getActiveUsers = asks( 120 | * deps => deps.userService.countActiveUsers() 121 | * ); 122 | * const getPendingOrders = asks( 123 | * deps => deps.orderService.countPendingOrders() 124 | * ); 125 | * 126 | * // Combine into a dashboard stats object 127 | * const getDashboardStats = traverse( 128 | * [getActiveUsers, getPendingOrders], 129 | * (acc, count, index) => { 130 | * if (index === 0) acc.activeUsers = count; 131 | * else if (index === 1) acc.pendingOrders = count; 132 | * return acc; 133 | * }, 134 | * { activeUsers: 0, pendingOrders: 0 } 135 | * ); 136 | */ 137 | export function traverse( 138 | readers: Array>, 139 | reducer: (acc: TAcc, value: TOut, index: number) => TAcc, 140 | initialValue: TAcc 141 | ): IReader { 142 | return Reader.traverse(readers, reducer, initialValue) 143 | } 144 | 145 | /** 146 | * Combines the results of multiple Readers with a mapping function. 147 | * 148 | * @typeParam TConfig - The shared environment type 149 | * @typeParam Args - Tuple type of Reader results 150 | * @typeParam R - The type of the combined result 151 | * @param readers - Tuple of Readers 152 | * @param fn - Function to combine the results 153 | * @returns A Reader that produces the combined result 154 | * 155 | * @example 156 | * // Define individual Readers 157 | * const getUser = asks(deps => deps.userService.getCurrentUser()); 158 | * const getPermissions = asks(deps => deps.authService.getPermissions()); 159 | * 160 | * // Combine them into a user profile object 161 | * const getUserProfile = combine( 162 | * [getUser, getPermissions], 163 | * (user, permissions) => ({ 164 | * id: user.id, 165 | * name: user.name, 166 | * permissions 167 | * }) 168 | * ); 169 | */ 170 | export function combine( 171 | readers: { [K in keyof Args]: IReader }, 172 | fn: (...args: Args) => R 173 | ): IReader { 174 | return Reader.combine(readers, fn) 175 | } 176 | -------------------------------------------------------------------------------- /src/reader/reader.interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a Reader monad for handling environment or configuration-based computations. 3 | * 4 | * The Reader monad provides a way to read from a shared environment and pass it through 5 | * a series of computations. It's useful for dependency injection, configuration handling, 6 | * and composing operations that all need access to the same context. 7 | * 8 | * Type parameters: 9 | * @typeParam E - The environment/configuration type 10 | * @typeParam A - The result type 11 | */ 12 | export interface IReader { 13 | /** 14 | * Creates a new Reader with the given function. 15 | * 16 | * @param fn - Function that takes a configuration and returns a value 17 | * @returns A new Reader containing the provided function 18 | */ 19 | of(fn: (config: E) => A): IReader 20 | 21 | /** 22 | * Executes the Reader's function with the provided configuration. 23 | * 24 | * @param config - The configuration to use 25 | * @returns The result of applying the Reader's function to the configuration 26 | */ 27 | run(config: E): A 28 | 29 | /** 30 | * Maps the output of this Reader using the provided function. 31 | * 32 | * Creates a new Reader that first applies this Reader's function to the configuration, 33 | * then applies the provided mapping function to the result. 34 | * 35 | * @typeParam B - The type of the mapped result 36 | * @param fn - Function to transform the output value 37 | * @returns A new Reader that produces the transformed output 38 | */ 39 | map(fn: (val: A) => B): IReader 40 | 41 | /** 42 | * Chains this Reader with another Reader-producing function. 43 | * 44 | * Creates a new Reader that first applies this Reader's function to the configuration, 45 | * then passes the result to the provided function to get a new Reader, which is then 46 | * run with the same configuration. 47 | * 48 | * @typeParam B - The type of the final result 49 | * @param fn - Function that takes the output of this Reader and returns a new Reader 50 | * @returns A new Reader representing the composed operation 51 | */ 52 | flatMap(fn: (val: A) => IReader): IReader 53 | 54 | /** 55 | * Creates a new Reader that applies the given function to the environment before 56 | * passing it to this Reader. 57 | * 58 | * This is used for modifying the environment/configuration before it reaches the 59 | * Reader's main function. 60 | * 61 | * @typeParam F - The type of the new environment 62 | * @param fn - Function to transform the environment 63 | * @returns A new Reader that operates on the new environment type 64 | */ 65 | local(fn: (env: F) => E): IReader 66 | 67 | /** 68 | * Applies a binary function to the results of two Readers. 69 | * 70 | * This method allows you to combine the results of this Reader with another Reader, 71 | * using both results as inputs to the provided combining function. 72 | * 73 | * @typeParam B - The type of the other Reader's result 74 | * @typeParam C - The type of the combined result 75 | * @param other - Another Reader to combine with this one 76 | * @param fn - Function that combines both Reader results 77 | * @returns A new Reader that produces the combined result 78 | */ 79 | zipWith(other: IReader, fn: (a: A, b: B) => C): IReader 80 | 81 | /** 82 | * Executes side-effect functions and returns the original Reader for chaining. 83 | * 84 | * This method allows you to perform an action using the Reader's value without 85 | * affecting the Reader itself. 86 | * 87 | * @param fn - A function to execute with the Reader's result value 88 | * @returns This Reader unchanged, for chaining 89 | */ 90 | tap(fn: (val: A) => void): IReader 91 | 92 | /** 93 | * Maps the output to a constant value. 94 | * 95 | * Creates a new Reader that produces the specified value, ignoring the actual output 96 | * of the original Reader's computation. 97 | * 98 | * @typeParam B - The type of the new constant value 99 | * @param val - The constant value to return 100 | * @returns A new Reader that always produces the specified value 101 | */ 102 | mapTo(val: B): IReader 103 | 104 | /** 105 | * Combines this Reader with another, ignoring the result of this Reader. 106 | * 107 | * This is useful when you want to perform a computation for its effects, 108 | * but use the result of another Reader. 109 | * 110 | * @typeParam B - The type of the other Reader's result 111 | * @param other - The Reader whose result will be used 112 | * @returns A new Reader that produces the second Reader's result 113 | */ 114 | andThen(other: IReader): IReader 115 | 116 | /** 117 | * Combines this Reader with another, ignoring the result of the other Reader. 118 | * 119 | * This is useful when you want to perform another computation for its effects, 120 | * but keep the result of this Reader. 121 | * 122 | * @typeParam B - The type of the other Reader's result 123 | * @param other - The Reader to execute after this one 124 | * @returns A new Reader that produces this Reader's result 125 | */ 126 | andFinally(other: IReader): IReader 127 | 128 | /** 129 | * Applies a function from the environment to transform the Reader's output. 130 | * 131 | * This method combines aspects of both `map` and `local` - it allows a transformation 132 | * based on both the environment and the current output value. 133 | * 134 | * @typeParam B - The type of the transformed result 135 | * @param fn - Function that takes the environment and the current output to produce a new output 136 | * @returns A new Reader that applies the environment-aware transformation 137 | */ 138 | withEnv(fn: (env: E, val: A) => B): IReader 139 | 140 | /** 141 | * Filters the output value using a predicate function. 142 | * 143 | * If the predicate returns true for the output value, the original value is kept. 144 | * If the predicate returns false, the provided default value is used instead. 145 | * 146 | * @param predicate - Function to test the output value 147 | * @param defaultValue - Value to use if the predicate returns false 148 | * @returns A new Reader with the filtered value 149 | */ 150 | filter(predicate: (val: A) => boolean, defaultValue: A): IReader 151 | 152 | /** 153 | * Composes multiple transformations of the same environment. 154 | * 155 | * This creates a new Reader that runs all provided transformation functions 156 | * and returns an array of their results. 157 | * 158 | * @param fns - Functions that transform the environment to various results 159 | * @returns A Reader that produces an array of all transformation results 160 | */ 161 | fanout(...fns: Array<(val: A) => B[number]>): IReader 162 | 163 | /** 164 | * Creates a Reader that runs asynchronously, returning a Promise. 165 | * 166 | * This method transforms a Reader into a function that takes an environment E 167 | * and returns a Promise, allowing for integration with async/await code. 168 | * 169 | * @returns A function that takes an environment and returns a Promise with the result 170 | */ 171 | toPromise(): (env: E) => Promise 172 | 173 | /** 174 | * Creates a Reader that caches its result for repeated calls with the same environment. 175 | * 176 | * This optimization is useful when the Reader's computation is expensive and 177 | * the same environment is used multiple times. 178 | * 179 | * @param cacheKeyFn - Optional function to derive a cache key from the environment 180 | * @returns A new Reader that caches results based on the environment 181 | */ 182 | memoize(cacheKeyFn?: (env: E) => string | number): IReader 183 | } 184 | -------------------------------------------------------------------------------- /src/list/list.spec.ts: -------------------------------------------------------------------------------- 1 | import { List } from './list' 2 | import { listFrom } from './list.factory' 3 | 4 | class Animal { 5 | constructor(public name: string, public nickname?: string) { } 6 | } 7 | 8 | class Dog extends Animal { 9 | dogtag!: string 10 | dogyear!: number 11 | } 12 | 13 | class Cat extends Animal { 14 | likesCatnip = true 15 | } 16 | 17 | describe(List.name, () => { 18 | describe('Integers', () => { 19 | it('should', () => { 20 | const sut = List 21 | .integers() 22 | .headOrUndefined() 23 | 24 | expect(sut).toEqual(0) 25 | }) 26 | }) 27 | 28 | describe('Range', () => { 29 | it('should support stepping', () => { 30 | const sut = List.range(4, 10, 2) 31 | 32 | expect(sut.toArray()).toEqual([4, 6, 8, 10]) 33 | }) 34 | }) 35 | 36 | describe('Empty', () => { 37 | it('should', () => { 38 | const sut = List.empty().toArray() 39 | 40 | expect(sut.length).toEqual(0) 41 | }) 42 | }) 43 | 44 | it('should spread to array', () => { 45 | const sut = List.of(1, 2, 6, 10).toArray() 46 | 47 | expect(sut).toEqual([1, 2, 6, 10]) 48 | }) 49 | 50 | it('should toIterable', () => { 51 | const sut = List.of(1, 2, 6, 10).toIterable() 52 | 53 | expect(sut).toEqual([1, 2, 6, 10]) 54 | }) 55 | 56 | it('sdasd', () => { 57 | const sut = List.from([1, 6]).toArray() 58 | 59 | expect(sut).toEqual([1, 6]) 60 | }) 61 | 62 | describe('should get head', () => { 63 | it('should ...', () => { 64 | const sut = List.from([1, 6]) 65 | 66 | expect(sut.headOr(0)).toEqual(1) 67 | expect(sut.headOr(3)).toEqual(1) 68 | }) 69 | 70 | it('should ...', () => { 71 | const sut = List.from([]).headOr(0) 72 | 73 | expect(sut).toEqual(0) 74 | }) 75 | 76 | it('should ...', () => { 77 | const sut = List.from([1]).headOr(0) 78 | 79 | expect(sut).toEqual(1) 80 | }) 81 | 82 | it('should headOrUndefined', () => { 83 | const sut1 = List.from([1]).headOrUndefined() 84 | const sut2 = List.from([]).headOrUndefined() 85 | 86 | expect(sut1).toEqual(1) 87 | expect(sut2).toBeUndefined() 88 | }) 89 | 90 | it('should headOrCompute', () => { 91 | const sut = List.from([]).headOrCompute(() => 67) 92 | 93 | expect(sut).toEqual(67) 94 | }) 95 | 96 | it('should headOrThrow', () => { 97 | expect(() => { 98 | List.from([]).headOrThrow('errrr') 99 | }).toThrowError('errrr') 100 | }) 101 | }) 102 | 103 | it('should range', () => { 104 | const sut = List.range(2, 5).toArray() 105 | 106 | expect(sut).toEqual([2, 3, 4, 5]) 107 | 108 | }) 109 | 110 | describe('should map', () => { 111 | it('should ...', () => { 112 | const sut = List.of(1, 2, 5) 113 | .map(x => x + 3) 114 | .toArray() 115 | 116 | expect(sut).toEqual([4, 5, 8]) 117 | }) 118 | }) 119 | 120 | describe('should scan', () => { 121 | it('should ...', () => { 122 | const sut = List.from([1, 2, 3, 4]) 123 | .scan((acc, curr) => curr + acc, 0) 124 | .toArray() 125 | 126 | expect(sut).toEqual([1, 3, 6, 10]) 127 | }) 128 | }) 129 | 130 | describe('should reduce', () => { 131 | it('should ...', () => { 132 | const sut = List.of(1, 2, 3, 4).reduce((acc, curr) => acc + curr, 0) 133 | 134 | expect(sut).toEqual(10) 135 | }) 136 | }) 137 | 138 | describe('should filter', () => { 139 | it('should ...', () => { 140 | const sut = List.of(1, 2, 5) 141 | .filter(x => x > 2) 142 | .toArray() 143 | 144 | expect(sut).toEqual([5]) 145 | }) 146 | 147 | it('should alias where to filter', () => { 148 | const sut = List.of(1, 2, 5) 149 | .where(x => x > 2) 150 | .toArray() 151 | 152 | expect(sut).toEqual([5]) 153 | }) 154 | }) 155 | 156 | it('should join arrays', () => { 157 | const sut = List.of(1) 158 | .concat(2) 159 | .concat(3) 160 | .concat(4, 5) 161 | .concat([6, 7]) 162 | .concat([8, 9], [10, 11]) 163 | .toArray() 164 | 165 | expect(sut).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) 166 | }) 167 | 168 | describe('should all', () => { 169 | it('should', () => { 170 | const sut = List.of('test 1', 'test 2', 'test 3') 171 | 172 | expect(sut.all(a => a.includes('test'))).toEqual(true) 173 | }) 174 | it('should', () => { 175 | const sut = List.of('test 1', 'UGH!', 'test 2', 'test 3') 176 | 177 | expect(sut.all(a => a.includes('test'))).toEqual(false) 178 | }) 179 | }) 180 | 181 | describe('should any', () => { 182 | it('should', () => { 183 | const sut = List.of('test 1', 'test 2', 'test 3') 184 | 185 | expect(sut.any(a => a.includes('test'))).toEqual(true) 186 | expect(sut.some(a => a.includes('test'))).toEqual(true) 187 | }) 188 | it('should', () => { 189 | const sut = List.of('test 1', 'UGH!', 'test 2', 'test 3') 190 | 191 | expect(sut.any(a => a.includes('NOTHERE'))).toEqual(false) 192 | expect(sut.some(a => a.includes('NOTHERE'))).toEqual(false) 193 | }) 194 | }) 195 | 196 | describe('take', () => { 197 | it('should ...', () => { 198 | const sut = List.of(1, 2, 3) 199 | 200 | expect(sut.take(3).toArray()).toEqual([1, 2, 3]) 201 | expect(sut.take(2).toArray()).toEqual([1, 2]) 202 | expect(sut.take(1).toArray()).toEqual([1]) 203 | expect(sut.take(0).toArray()).toEqual([]) 204 | }) 205 | }) 206 | 207 | describe('InstanceOf', () => { 208 | it('should filter on instance', () => { 209 | const dog = new Dog('Rex') 210 | const cat = new Cat('Meow') 211 | const sut = List.of(dog, cat) 212 | 213 | expect(sut.ofType(Cat).toArray().length).toEqual(1) 214 | expect(sut.ofType(Cat).toArray()).toEqual([cat]) 215 | expect(sut.ofType(Dog).toArray().length).toEqual(1) 216 | expect(sut.ofType(Dog).toArray()).toEqual([dog]) 217 | }) 218 | }) 219 | 220 | describe('Drop', () => { 221 | it('should', () => { 222 | const sut = List.of(1, 5, 10, 15, 20).drop(1).drop(1).toArray() 223 | const sut2 = listFrom(sut).drop(2).toArray() 224 | const sut3 = listFrom(sut2).tail().toArray() 225 | 226 | expect(sut).toEqual([10, 15, 20]) 227 | expect(sut2).toEqual([20]) 228 | expect(sut3).toEqual([]) 229 | }) 230 | }) 231 | 232 | describe('ToDictionary', () => { 233 | const Rex = new Dog('Rex', 'Rdawg') 234 | const Meow = new Cat('Meow') 235 | const sut = List.of(Rex, Meow) 236 | 237 | it('should handle nominal keyed case', () => { 238 | expect(sut.toDictionary('name')).toEqual({ Rex, Meow }) 239 | }) 240 | 241 | it('should handle unkeyed', () => { 242 | expect(sut.toDictionary()).toEqual({ 0: Rex, 1: Meow }) 243 | }) 244 | 245 | it('should handle missing keys', () => { 246 | expect(sut.toDictionary('nickname')).toEqual({ Rdawg: Rex }) 247 | }) 248 | }) 249 | 250 | describe('sum', () => { 251 | it('should sum the list', () => { 252 | const sut = List.of(3, 20, 10) 253 | const sut2 = List.of('how sume this?', 'no way') 254 | 255 | expect(sut.sum()).toEqual(33) 256 | expect(sut2.sum()).toEqual(0) 257 | }) 258 | }) 259 | 260 | // describe('OrderBy', () => { 261 | // it('should order by object', () => { 262 | // const dog1 = new Dog('Atlas') 263 | // const dog2 = new Dog('Zues') 264 | // const sut = List.of(dog1, dog2) 265 | 266 | // expect(sut.orderBy('dogtag').toEqual([])) 267 | // expect(sut.orderBy('name')).toEqual([]) 268 | // }) 269 | 270 | // it('should order by number', () => { 271 | // const sut = List.of(1, 2, 5, 3, 12) 272 | 273 | // expect(sut.orderBy().toEqual([])) 274 | // expect(sut.orderBy()).toEqual([]) 275 | // }) 276 | 277 | // it('should order by string', () => { 278 | // const sut = List.of('abc', 'efg', 'zel', 'lmao') 279 | 280 | // expect(sut.orderBy().toEqual([])) 281 | // expect(sut.orderBy()).toEqual([]) 282 | // }) 283 | // }) 284 | }) 285 | -------------------------------------------------------------------------------- /src/reader/README.md: -------------------------------------------------------------------------------- 1 | # Reader Monad 2 | 3 | The Reader monad is a powerful functional programming pattern for handling dependencies and configuration. It provides a clean way to access shared environment or configuration data throughout a computation without explicitly passing it around. 4 | 5 | ## Core Concept 6 | 7 | A Reader monad is essentially a function that: 8 | 9 | 1. Takes an environment/configuration as input 10 | 2. Performs a computation using that environment 11 | 3. Returns a result 12 | 13 | The magic happens when you compose multiple Readers together - each Reader in the chain has access to the same environment, making dependency injection simple and pure. 14 | 15 | ## Key Benefits 16 | 17 | - **Dependency Injection**: Pass dependencies around implicitly without global state 18 | - **Testability**: Easily swap environments for testing 19 | - **Composability**: Combine small, focused Readers into complex operations 20 | - **Type Safety**: Environment requirements are encoded in the type system 21 | - **Pure Functional**: No side effects or hidden dependencies 22 | - **Lazy Evaluation**: Operations are only run when the final Reader is executed 23 | 24 | ## Basic Usage 25 | 26 | ```typescript 27 | import { reader, asks } from 'typescript-monads' 28 | 29 | // Create a Reader that extracts a value from the environment 30 | const getApiUrl = asks(config => config.apiUrl) 31 | 32 | // Create a Reader that uses the environment to format a URL 33 | const getFullUrl = reader(config => 34 | `${config.apiUrl}/users?token=${config.authToken}` 35 | ) 36 | 37 | // Run the Reader with a configuration 38 | const url = getFullUrl.run({ 39 | apiUrl: 'https://api.example.com', 40 | authToken: '12345' 41 | }) // "https://api.example.com/users?token=12345" 42 | ``` 43 | 44 | ## Core Operations 45 | 46 | ### Creation 47 | 48 | ```typescript 49 | // Create from a function that uses the environment 50 | const greeting = reader<{name: string}, string>(env => `Hello, ${env.name}!`) 51 | 52 | // Create a Reader that always returns a constant value (ignores environment) 53 | const constant = readerOf(42) 54 | 55 | // Create a Reader that returns the entire environment 56 | const getEnv = ask() 57 | 58 | // Create a Reader that extracts a specific value from the environment 59 | const getTimeout = asks(config => config.timeout) 60 | ``` 61 | 62 | ### Transformation 63 | 64 | ```typescript 65 | // Map the output value 66 | const getApiUrl = asks(c => c.apiUrl) 67 | const getApiUrlUpper = getApiUrl.map(url => url.toUpperCase()) 68 | 69 | // Chain Readers 70 | const getUser = asks(c => c.currentUser) 71 | const getPermissions = (user: User) => 72 | asks(c => c.permissionsDb.getPermissionsFor(user.id)) 73 | 74 | // Combined operation: get user and their permissions 75 | const getUserPermissions = getUser.flatMap(getPermissions) 76 | ``` 77 | 78 | ### Environment Manipulation 79 | 80 | ```typescript 81 | // Create a Reader that works with a specific config type 82 | const getDatabaseUrl = reader(db => 83 | `postgres://${db.host}:${db.port}/${db.name}` 84 | ) 85 | 86 | // Adapt it to work with a different environment type 87 | const getDbFromAppConfig = getDatabaseUrl.local(app => app.database) 88 | 89 | // Now it works with AppConfig 90 | const url = getDbFromAppConfig.run({ 91 | database: { host: 'localhost', port: 5432, name: 'myapp' }, 92 | // other app config... 93 | }) 94 | ``` 95 | 96 | ### Combining Readers 97 | 98 | ```typescript 99 | // Combine multiple Readers into an array of results 100 | const getName = asks(u => u.name) 101 | const getAge = asks(u => u.age) 102 | const getEmail = asks(u => u.email) 103 | 104 | const getUserInfo = sequence([getName, getAge, getEmail]) 105 | // getUserInfo.run(user) returns [name, age, email] 106 | 107 | // Combine multiple Readers with a mapping function 108 | const getUserSummary = combine( 109 | [getName, getAge, getEmail], 110 | (name, age, email) => `${name} (${age}) - ${email}` 111 | ) 112 | // getUserSummary.run(user) returns "Alice (30) - alice@example.com" 113 | 114 | // Combine two Readers with a binary function 115 | const greeting = asks(c => c.greeting) 116 | const username = asks(c => c.username) 117 | 118 | const personalizedGreeting = greeting.zipWith( 119 | username, 120 | (greet, name) => `${greet}, ${name}!` 121 | ) 122 | // personalizedGreeting.run({greeting: "Hello", username: "Bob"}) returns "Hello, Bob!" 123 | ``` 124 | 125 | ## Advanced Features 126 | 127 | ### Side Effects 128 | 129 | ```typescript 130 | // Execute a side effect without changing the Reader value 131 | const loggedApiUrl = getApiUrl.tap(url => console.log(`Using API URL: ${url}`)) 132 | 133 | // Chain Readers for sequencing operations 134 | const logAndGetUser = loggerReader.andThen(getUserReader) 135 | ``` 136 | 137 | ### Environment-Aware Transformations 138 | 139 | ```typescript 140 | // Transform using both environment and current value 141 | const getTemplate = asks(c => c.template) 142 | const getMessage = getTemplate.withEnv( 143 | (config, template) => template.replace('{user}', config.currentUser) 144 | ) 145 | ``` 146 | 147 | ### Filtering and Multiple Transformations 148 | 149 | ```typescript 150 | // Filter values based on a predicate 151 | const getAge = asks(p => p.age) 152 | const getValidAge = getAge.filter( 153 | age => age >= 0 && age <= 120, 154 | 0 // Default for invalid ages 155 | ) 156 | 157 | // Apply multiple transformations to the same value 158 | const getUserStats = getUser.fanout( 159 | user => user.loginCount, 160 | user => user.lastActive, 161 | user => user.preferences.theme 162 | ) 163 | // Returns [loginCount, lastActive, theme] 164 | ``` 165 | 166 | ### Async Integration and Performance 167 | 168 | ```typescript 169 | // Convert a Reader to a Promise-returning function 170 | const processConfig = asks(c => computeResult(c)) 171 | const processAsync = processConfig.toPromise() 172 | 173 | // Later in async code 174 | const result = await processAsync(myConfig) 175 | 176 | // Cache expensive Reader operations 177 | const expensiveReader = reader(c => expensiveComputation(c)).memoize() 178 | ``` 179 | 180 | ## Real-World Examples 181 | 182 | ### Dependency Injection 183 | 184 | ```typescript 185 | // Define dependencies interface 186 | interface AppDependencies { 187 | logger: Logger 188 | database: Database 189 | apiClient: ApiClient 190 | } 191 | 192 | // Create Readers for each dependency 193 | const getLogger = asks(deps => deps.logger) 194 | const getDb = asks(deps => deps.database) 195 | const getApiClient = asks(deps => deps.apiClient) 196 | 197 | // Create business logic using dependencies 198 | const getUserById = (id: string) => combine( 199 | [getDb, getLogger], 200 | (db, logger) => { 201 | logger.info(`Fetching user ${id}`) 202 | return db.users.findById(id) 203 | } 204 | ) 205 | 206 | // Configure the real dependencies 207 | const dependencies: AppDependencies = { 208 | logger: new ConsoleLogger(), 209 | database: new PostgresDatabase(dbConfig), 210 | apiClient: new HttpApiClient(apiConfig) 211 | } 212 | 213 | // Run the Reader with the dependencies 214 | const user = getUserById('123').run(dependencies) 215 | ``` 216 | 217 | ### Configuration Management 218 | 219 | ```typescript 220 | // Different sections of configuration 221 | const getDbConfig = asks(c => c.database) 222 | const getApiConfig = asks(c => c.api) 223 | const getEnvironment = asks(c => c.environment) 224 | 225 | // Create environment-specific database URL 226 | const getDatabaseUrl = combine( 227 | [getDbConfig, getEnvironment], 228 | (db, env) => { 229 | const { host, port, name, user, password } = db 230 | const dbName = env === 'test' ? `${name}_test` : name 231 | return `postgres://${user}:${password}@${host}:${port}/${dbName}` 232 | } 233 | ) 234 | ``` 235 | 236 | ## Benefits Over Direct Approaches 237 | 238 | | Problem | Traditional Approach | Reader Monad Solution | 239 | |---------|---------------------|------------------------| 240 | | Dependency Injection | Constructor injection, service locators | Implicit dependencies via the environment | 241 | | Configuration | Passing config objects, globals | Environment access in pure functions | 242 | | Testing | Mocking, dependency overrides | Simply passing different environments | 243 | | Composition | Complex chaining with explicit parameters | Clean composition with flatMap and combine | 244 | | Reuse | Duplicating config parameters | Single environment shared by multiple Readers | -------------------------------------------------------------------------------- /src/result/result.methods.spec.ts: -------------------------------------------------------------------------------- 1 | import { ok, fail } from './public_api' 2 | import { of, throwError, EMPTY } from 'rxjs' 3 | 4 | describe('Result instance methods', () => { 5 | describe('recover', () => { 6 | it('should not transform an Ok Result', () => { 7 | // Arrange 8 | const okResult = ok(5) 9 | 10 | // Act 11 | const result = okResult.recover(() => 10) 12 | 13 | // Assert 14 | expect(result.isOk()).toBe(true) 15 | expect(result.unwrap()).toBe(5) 16 | }) 17 | 18 | it('should transform a Fail Result to an Ok Result', () => { 19 | // Arrange 20 | const failResult = fail('Error') 21 | 22 | // Act 23 | const result = failResult.recover(() => 10) 24 | 25 | // Assert 26 | expect(result.isOk()).toBe(true) 27 | expect(result.unwrap()).toBe(10) 28 | }) 29 | }) 30 | 31 | describe('recoverWith', () => { 32 | it('should not transform an Ok Result', () => { 33 | // Arrange 34 | const okResult = ok(5) 35 | 36 | // Act 37 | const result = okResult.recoverWith(() => ok(10)) 38 | 39 | // Assert 40 | expect(result.isOk()).toBe(true) 41 | expect(result.unwrap()).toBe(5) 42 | }) 43 | 44 | it('should transform a Fail Result using the provided function', () => { 45 | // Arrange 46 | const failResult = fail('Error') 47 | 48 | // Act 49 | const result = failResult.recoverWith(() => ok(10)) 50 | 51 | // Assert 52 | expect(result.isOk()).toBe(true) 53 | expect(result.unwrap()).toBe(10) 54 | }) 55 | 56 | it('should allow returning a new Fail Result from the recovery function', () => { 57 | // Arrange 58 | const failResult = fail('Original error') 59 | 60 | // Act 61 | const result = failResult.recoverWith(err => 62 | fail(`Transformed: ${err}`) 63 | ) 64 | 65 | // Assert 66 | expect(result.isFail()).toBe(true) 67 | expect(result.unwrapFail()).toBe('Transformed: Original error') 68 | }) 69 | }) 70 | 71 | describe('orElse', () => { 72 | it('should return the original Result when it is Ok', () => { 73 | // Arrange 74 | const okResult = ok(5) 75 | const fallback = ok(10) 76 | 77 | // Act 78 | const result = okResult.orElse(fallback) 79 | 80 | // Assert 81 | expect(result.isOk()).toBe(true) 82 | expect(result.unwrap()).toBe(5) 83 | }) 84 | 85 | it('should return the fallback Result when the original is Fail', () => { 86 | // Arrange 87 | const failResult = fail('Error') 88 | const fallback = ok(10) 89 | 90 | // Act 91 | const result = failResult.orElse(fallback) 92 | 93 | // Assert 94 | expect(result.isOk()).toBe(true) 95 | expect(result.unwrap()).toBe(10) 96 | }) 97 | 98 | it('should work with fallback that is also a Fail Result', () => { 99 | // Arrange 100 | const failResult = fail('Original error') 101 | const fallback = fail('Fallback error') 102 | 103 | // Act 104 | const result = failResult.orElse(fallback) 105 | 106 | // Assert 107 | expect(result.isFail()).toBe(true) 108 | expect(result.unwrapFail()).toBe('Fallback error') 109 | }) 110 | }) 111 | 112 | describe('swap', () => { 113 | it('should transform an Ok Result to a Fail Result', () => { 114 | // Arrange 115 | const okResult = ok(5) 116 | 117 | // Act 118 | const result = okResult.swap() 119 | 120 | // Assert 121 | expect(result.isFail()).toBe(true) 122 | expect(result.unwrapFail()).toBe(5) 123 | }) 124 | 125 | it('should transform a Fail Result to an Ok Result', () => { 126 | // Arrange 127 | const failResult = fail('Error') 128 | 129 | // Act 130 | const result = failResult.swap() 131 | 132 | // Assert 133 | expect(result.isOk()).toBe(true) 134 | expect(result.unwrap()).toBe('Error') 135 | }) 136 | }) 137 | 138 | describe('zipWith', () => { 139 | it('should combine two Ok Results using the provided function', () => { 140 | // Arrange 141 | const result1 = ok(5) 142 | const result2 = ok('test') 143 | 144 | // Act 145 | const combined = result1.zipWith(result2, (a, b) => `${a}-${b}`) 146 | 147 | // Assert 148 | expect(combined.isOk()).toBe(true) 149 | expect(combined.unwrap()).toBe('5-test') 150 | }) 151 | 152 | it('should return the first Fail Result when the first Result is Fail', () => { 153 | // Arrange 154 | const result1 = fail('Error 1') 155 | const result2 = ok('test') 156 | 157 | // Act 158 | const combined = result1.zipWith(result2, (a, b) => `${a}-${b}`) 159 | 160 | // Assert 161 | expect(combined.isFail()).toBe(true) 162 | expect(combined.unwrapFail()).toBe('Error 1') 163 | }) 164 | 165 | it('should return the second Fail Result when the second Result is Fail', () => { 166 | // Arrange 167 | const result1 = ok(5) 168 | const result2 = fail('Error 2') 169 | 170 | // Act 171 | const combined = result1.zipWith(result2, (a, b) => `${a}-${b}`) 172 | 173 | // Assert 174 | expect(combined.isFail()).toBe(true) 175 | expect(combined.unwrapFail()).toBe('Error 2') 176 | }) 177 | }) 178 | 179 | describe('flatMapPromise', () => { 180 | it('should map an Ok Result to a Promise and flatten', async () => { 181 | // Arrange 182 | const okResult = ok(5) 183 | 184 | // Act 185 | const result = await okResult.flatMapPromise(n => Promise.resolve(n * 2)) 186 | 187 | // Assert 188 | expect(result.isOk()).toBe(true) 189 | expect(result.unwrap()).toBe(10) 190 | }) 191 | 192 | it('should convert a rejected Promise to a Fail Result', async () => { 193 | // Arrange 194 | const okResult = ok(5) 195 | const error = new Error('Promise error') 196 | 197 | // Act 198 | const result = await okResult.flatMapPromise(() => Promise.reject(error)) 199 | 200 | // Assert 201 | expect(result.isFail()).toBe(true) 202 | expect(result.unwrapFail()).toBe(error) 203 | }) 204 | 205 | it('should short-circuit for Fail Results without calling the function', async () => { 206 | // Arrange 207 | const failResult = fail('Original error') 208 | let functionCalled = false 209 | 210 | // Act 211 | const result = await failResult.flatMapPromise(() => { 212 | functionCalled = true 213 | return Promise.resolve(10) 214 | }) 215 | 216 | // Assert 217 | expect(functionCalled).toBe(false) 218 | expect(result.isFail()).toBe(true) 219 | expect(result.unwrapFail()).toBe('Original error') 220 | }) 221 | }) 222 | 223 | describe('flatMapObservable', () => { 224 | it('should map an Ok Result to an Observable and flatten', async () => { 225 | // Arrange 226 | const okResult = ok(5) 227 | 228 | // Act 229 | const result = await okResult.flatMapObservable( 230 | n => of(n * 2), 231 | 'Default error' 232 | ) 233 | 234 | // Assert 235 | expect(result.isOk()).toBe(true) 236 | expect(result.unwrap()).toBe(10) 237 | }) 238 | 239 | it('should convert an Observable error to a Fail Result', async () => { 240 | // Arrange 241 | const okResult = ok(5) 242 | const error = new Error('Observable error') 243 | 244 | // Act 245 | const result = await okResult.flatMapObservable( 246 | () => throwError(() => error), 247 | 'Default error' 248 | ) 249 | 250 | // Assert 251 | expect(result.isFail()).toBe(true) 252 | expect(result.unwrapFail()).toBe(error) 253 | }) 254 | 255 | it('should convert an empty Observable to a Fail Result with default error', async () => { 256 | // Arrange 257 | const okResult = ok(5) 258 | 259 | // Act 260 | const result = await okResult.flatMapObservable( 261 | () => EMPTY, 262 | 'Default error' 263 | ) 264 | 265 | // Assert 266 | expect(result.isFail()).toBe(true) 267 | expect(result.unwrapFail()).toBe('Default error') 268 | }) 269 | 270 | it('should short-circuit for Fail Results without calling the function', async () => { 271 | // Arrange 272 | const failResult = fail('Original error') 273 | let functionCalled = false 274 | 275 | // Act 276 | const result = await failResult.flatMapObservable(() => { 277 | functionCalled = true 278 | return of(10) 279 | }, 'Default error') 280 | 281 | // Assert 282 | expect(functionCalled).toBe(false) 283 | expect(result.isFail()).toBe(true) 284 | expect(result.unwrapFail()).toBe('Original error') 285 | }) 286 | }) 287 | }) 288 | -------------------------------------------------------------------------------- /src/list/list.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // Repurposed from this great piece of code: https://gist.github.com/gvergnaud/6e9de8e06ef65e65f18dbd05523c7ca9 3 | // Implements a number of functions from the .NET LINQ library: https://docs.microsoft.com/en-us/dotnet/api/system.linq.enumerable.reverse?view=netcore-3.1 4 | 5 | /** 6 | * A lazily evaluated list with useful extension methods. 7 | */ 8 | export class List { 9 | [k: string]: any; 10 | 11 | constructor(generator: () => Generator, private readonly length: number) { 12 | this[Symbol.iterator as any] = generator 13 | } 14 | 15 | private generator(): Generator { 16 | return this[Symbol.iterator as any]() as Generator 17 | } 18 | 19 | private static flattenArgs(args: T[] | Iterable[]): T[] { 20 | return (args as T[]) 21 | .reduce((acc, curr) => { 22 | return Array.isArray(curr) 23 | ? [...acc, ...curr] 24 | : [...acc, curr] 25 | }, [] as T[]) 26 | } 27 | 28 | public static of(...args: T[]): List { 29 | return new List(<() => Generator>function* () { 30 | yield* args 31 | return args 32 | }, args.length) 33 | } 34 | 35 | public static from(iterable?: Iterable): List { 36 | return iterable 37 | ? new List(function* () { 38 | yield* iterable as any 39 | } as any, (iterable as any).length) 40 | : List.empty() 41 | } 42 | 43 | public static range(start: number, end: number, step = 1): List { 44 | return new List(function* () { 45 | let i = start 46 | while (i <= end) { 47 | yield i 48 | i += step 49 | } 50 | } as any, Math.floor((end - start + 1) / step)) 51 | } 52 | 53 | public static integers(): List { 54 | return this.range(0, Infinity) 55 | } 56 | 57 | public static empty(): List { 58 | return new List(function* () { } as any, 0) 59 | } 60 | 61 | public map(fn: (val: T) => B): List { 62 | const generator = this.generator() as any 63 | return new List(function* () { 64 | for (const value of generator) { 65 | yield fn(value) as B 66 | } 67 | } as any, this.length) 68 | } 69 | 70 | /** 71 | * Delete the first N elements from a list. 72 | * @param count 73 | */ 74 | public drop(count: number): List { 75 | const generator = this.generator() as any 76 | return new List(function* () { 77 | let next = generator.next() 78 | let n = 1 79 | 80 | while (!next.done) { 81 | if (n > count) yield next.value 82 | n++ 83 | next = generator.next() 84 | } 85 | } as any, this.length - count) 86 | } 87 | 88 | /** 89 | * Deletes the first element from a list. 90 | * @param count 91 | */ 92 | tail(): List { 93 | return this.drop(1) 94 | } 95 | 96 | public scan(fn: (acc: B, val: B) => B, seed: B): List { 97 | const generator = this.generator() as any 98 | return new List(function* () { 99 | let acc = seed 100 | for (const value of generator) { 101 | yield acc = fn(acc, value) 102 | } 103 | } as any, this.length) 104 | } 105 | 106 | public reduce(fn: (previousValue: B, currentValue: T, currentIndex: number, array: T[]) => B, seed: B): B { 107 | return this.toArray().reduce(fn, seed) 108 | } 109 | 110 | /** 111 | * Filters a sequence of values based on a predicate. 112 | * @param fn A function to test each element for a condition. 113 | */ 114 | public filter(fn: (val: T) => boolean): List { 115 | const generator = this.generator() as any 116 | return new List(function* () { 117 | for (const value of generator) { 118 | if (fn(value)) yield value 119 | } 120 | } as any, this.length) 121 | } 122 | 123 | /** 124 | * Filters a sequence of values based on a predicate. Alias to filter 125 | * @param fn A function to test each element for a condition. 126 | */ 127 | public where(fn: (val: T) => boolean): List { 128 | return this.filter(fn) 129 | } 130 | 131 | public concat(...args: T[]): List 132 | public concat(...iterable: Iterable[]): List 133 | public concat(...args: T[] | Iterable[]): List { 134 | const generator = this.generator() as any 135 | const toAdd = List.flattenArgs(args) 136 | 137 | return new List(function* () { 138 | yield* generator 139 | yield* toAdd 140 | } as any, this.length + toAdd.length) 141 | } 142 | 143 | /** 144 | * Make a new list containing just the first N elements from an existing list. 145 | * @param count The number of elements to return. 146 | */ 147 | public take(count: number): List { 148 | const generator = this.generator() as any 149 | return new List(function* () { 150 | 151 | let next = generator.next() 152 | let n = 0 153 | 154 | while (!next.done && count > n) { 155 | yield next.value 156 | n++ 157 | next = generator.next() 158 | } 159 | } as any, this.length > count ? count : this.length) 160 | } 161 | 162 | /** 163 | * Determines whether all elements of a sequence satisfy a condition. 164 | */ 165 | public all(fn: (val: T) => boolean): boolean { 166 | const generator = this.generator() 167 | const newList = new List(<() => Generator>function* () { 168 | for (const value of generator as IterableIterator) { 169 | if (fn(value)) { 170 | yield value 171 | } else { 172 | return yield value 173 | } 174 | } 175 | } as any, this.length) 176 | 177 | return newList.toArray().length === this.length 178 | } 179 | 180 | /** 181 | * Determines whether a sequence contains any elements matching the predicate. 182 | * @param fn A function to test each element for a condition. 183 | */ 184 | public any(fn: (val: T) => boolean): boolean { 185 | const generator = this.generator() 186 | const newList = new List(<() => Generator>function* () { 187 | for (const value of generator as IterableIterator) { 188 | if (fn(value)) { 189 | return yield value 190 | } 191 | } 192 | } as any, this.length) 193 | 194 | return newList.toArray().length >= 1 195 | } 196 | 197 | /** 198 | * Determines whether a sequence contains any elements matching the predicate. 199 | * @param fn A function to test each element for a condition. 200 | * Aliased to any() 201 | */ 202 | public some(fn: (val: T) => boolean): boolean { 203 | return this.any(fn) 204 | } 205 | 206 | /** 207 | * Filters the elements of the list based on a specified type. 208 | * @param type The type to filter the elements of the sequence on. 209 | */ 210 | // eslint-disable-next-line @typescript-eslint/ban-types 211 | public ofType(type: Function): List { 212 | return this.filter(a => a instanceof type) 213 | } 214 | 215 | /** 216 | * Converts the list into an object with numbered indices mathing the array position of the item. 217 | */ 218 | public toDictionary(): { [key: number]: T } 219 | 220 | /** 221 | * Converts the list into an object deriving key from the specified property. 222 | */ 223 | public toDictionary(key: keyof T): { [key: string]: T } 224 | public toDictionary(key?: keyof T): { [key: number]: T } | { [key: string]: T } { 225 | return this.reduce((acc, curr, idx) => { 226 | return key 227 | ? curr[key] 228 | ? { ...acc, [curr[key] as any]: curr } 229 | : acc 230 | : { ...acc, [idx]: curr } 231 | }, {}) 232 | } 233 | 234 | // /** 235 | // * Sorts the elements of a sequence in ascending order. 236 | // */ 237 | // public orderBy(prop?: T extends object ? K : never): List { 238 | // throw Error('Not Implemented') 239 | // } 240 | 241 | // public orderByDescending(): List { 242 | // throw Error('Not Implemented') 243 | // } 244 | 245 | /** 246 | * Inverts the order of the elements in a sequence. 247 | */ 248 | // reverse(): List { 249 | // throw new Error('Not Implemented') 250 | // } 251 | 252 | sum(): number { 253 | return this 254 | .toArray() 255 | .reduce((acc, curr) => { 256 | return typeof curr === 'number' 257 | ? acc + curr 258 | : 0 259 | }, 0) 260 | } 261 | 262 | /** 263 | * Gets the first item in the collection or returns the provided value when undefined 264 | */ 265 | public headOr(valueWhenUndefined: T): T { 266 | return this.headOrUndefined() || valueWhenUndefined 267 | } 268 | 269 | /** 270 | * Gets the first item in the collection or returns undefined 271 | */ 272 | public headOrUndefined(): T | undefined { 273 | return this.generator().next().value as T 274 | } 275 | 276 | /** 277 | * Gets the first item in the collection or returns a computed function 278 | */ 279 | public headOrCompute(fn: () => NonNullable): T { 280 | return this.headOrUndefined() || fn() 281 | } 282 | 283 | /** 284 | * Gets the first item in the collection or throws an error if undefined 285 | */ 286 | public headOrThrow(msg?: string): T { 287 | return this.headOrUndefined() || ((): never => { throw new Error(msg) })() 288 | } 289 | 290 | /** Convert to standard array */ 291 | public toArray(): T[] { 292 | return [...this as any] as T[] 293 | } 294 | 295 | /** Convert to standard array. Aliased to toArray() */ 296 | public toIterable(): Iterable { 297 | return this.toArray() 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/maybe/maybe.ts: -------------------------------------------------------------------------------- 1 | import type { IResult } from '../result/result.interface' 2 | import type { IMaybePattern, IMaybe } from './maybe.interface' 3 | import { FailResult, OkResult } from '../result/result' 4 | 5 | export class Maybe implements IMaybe { 6 | constructor(private readonly value?: T | null) { } 7 | 8 | public of(value: T): IMaybe { 9 | return new Maybe(value) 10 | } 11 | 12 | public static none(): IMaybe { 13 | return new Maybe() 14 | } 15 | 16 | public static some(value: T): IMaybe { 17 | return new Maybe(value) 18 | } 19 | 20 | /** 21 | * Creates a Maybe from a Promise. 22 | * 23 | * Creates a Maybe that will resolve to: 24 | * - Some containing the resolved value if the promise resolves successfully 25 | * - None if the promise rejects or resolves to null/undefined 26 | * 27 | * @param promise The promise to convert to a Maybe 28 | * @returns A Promise that resolves to a Maybe containing the result 29 | * 30 | * @example 31 | * // Convert a promise to a Maybe 32 | * Maybe.fromPromise(api.fetchUser(userId)) 33 | * .then(userMaybe => userMaybe.match({ 34 | * some: user => console.log(user.name), 35 | * none: () => console.log('User not found') 36 | * })); 37 | */ 38 | /** 39 | * Creates a Maybe from a Promise. 40 | * 41 | * Creates a Promise that will resolve to a Maybe that is: 42 | * - Some containing the resolved value if the promise resolves successfully to a non-nullish value 43 | * - None if the promise rejects or resolves to null/undefined 44 | * 45 | * Note on resolution preservation: This static method maps the Promise resolution semantics 46 | * to the Maybe context in a natural way: 47 | * - Promise resolution (success) becomes Some (presence of value) 48 | * - Promise rejection (failure) becomes None (absence of value) 49 | * 50 | * This provides a clean way to convert from error-based to optional-based handling, 51 | * which is often more appropriate for values that might legitimately be missing. 52 | * 53 | * @param promise The promise to convert to a Maybe 54 | * @returns A Promise that resolves to a Maybe containing the result 55 | * 56 | * @example 57 | * // Convert a promise to a Maybe and use in a chain 58 | * Maybe.fromPromise(api.fetchUser(userId)) 59 | * .then(userMaybe => userMaybe.match({ 60 | * some: user => renderUser(user), 61 | * none: () => showUserNotFound() 62 | * })); 63 | */ 64 | public static fromPromise(promise: Promise): Promise>> { 65 | return promise 66 | .then((value: T) => new Maybe>(value as NonNullable)) 67 | .catch(() => new Maybe>()) 68 | } 69 | 70 | /** 71 | * Creates a Maybe from an Observable. 72 | * 73 | * Creates a Promise that will resolve to a Maybe that is: 74 | * - Some containing the first emitted value if the observable emits a non-nullish value 75 | * - None if the observable completes without emitting, errors, or emits a nullish value 76 | * 77 | * Note on resolution transformation: This method transforms the reactive semantics of 78 | * an Observable to the optional semantics of a Maybe: 79 | * - Observable emissions (data) become Some values (presence) 80 | * - Observable completion without emissions (empty success) becomes None (absence) 81 | * - Observable errors (failure) become None (absence) 82 | * 83 | * Note on timing model: This transformation changes from a continuous/reactive model 84 | * to a one-time asynchronous result. Only the first emission is captured, and the 85 | * reactive nature of the Observable is lost after transformation. 86 | * 87 | * @param observable The observable to convert to a Maybe 88 | * @returns A Promise that resolves to a Maybe containing the first emitted value 89 | * 90 | * @requires rxjs@^7.0 91 | * @example 92 | * // Convert an observable to a Maybe and use in a chain 93 | * Maybe.fromObservable(userService.getUser(userId)) 94 | * .then(userMaybe => userMaybe 95 | * .map(user => user.name) 96 | * .valueOr('Guest')) 97 | * .then(name => displayUserName(name)); 98 | */ 99 | public static fromObservable(observable: import('rxjs').Observable): Promise>> { 100 | return import('rxjs').then(({ firstValueFrom, EMPTY, take, map, catchError }) => { 101 | return firstValueFrom( 102 | observable.pipe( 103 | take(1), 104 | map((value) => new Maybe>(value as NonNullable)), 105 | catchError(() => EMPTY) 106 | ) 107 | ).then( 108 | (maybeValue: IMaybe>) => maybeValue, 109 | () => new Maybe>() 110 | ) 111 | }) 112 | } 113 | 114 | /** 115 | * Transforms an array of Maybe values into a Maybe containing an array of values. 116 | * 117 | * If all Maybes in the input array are Some, returns a Some containing an array of all values. 118 | * If any Maybe in the input array is None, returns None. 119 | * 120 | * @param maybes An array of Maybe values 121 | * @returns A Maybe containing an array of all values if all inputs are Some, otherwise None 122 | * 123 | * @example 124 | * // All Maybes are Some 125 | * const result1 = Maybe.sequence([maybe(1), maybe(2), maybe(3)]); 126 | * // result1 is Some([1, 2, 3]) 127 | * 128 | * // One Maybe is None 129 | * const result2 = Maybe.sequence([maybe(1), maybe(null), maybe(3)]); 130 | * // result2 is None 131 | */ 132 | public static sequence(maybes: ReadonlyArray>): IMaybe> { 133 | if (maybes.length === 0) { 134 | return new Maybe>([]) 135 | } 136 | 137 | const values: T[] = [] 138 | for (const m of maybes) { 139 | if (m.isNone()) { 140 | return new Maybe>() 141 | } 142 | values.push(m.valueOrThrow()) 143 | } 144 | return new Maybe>(values) 145 | } 146 | 147 | public isSome(): boolean { 148 | return !this.isNone() 149 | } 150 | 151 | public isNone(): boolean { 152 | return this.value === null || this.value === undefined 153 | } 154 | 155 | public valueOr(value: NonNullable): NonNullable { 156 | return this.isSome() ? this.value as NonNullable : value 157 | } 158 | 159 | public valueOrUndefined(): T | undefined { 160 | return this.isSome() ? this.value as NonNullable : undefined 161 | } 162 | 163 | public valueOrNull(): T | null { 164 | return this.isSome() ? this.value as NonNullable : null 165 | } 166 | 167 | public valueOrCompute(fn: () => NonNullable): NonNullable { 168 | return this.isSome() ? this.value as NonNullable : fn() 169 | } 170 | 171 | public valueOrThrow(msg?: string): NonNullable { 172 | return this.isNone() ? ((): never => { throw new Error(msg) })() : this.value as NonNullable 173 | } 174 | 175 | public valueOrThrowErr(err?: Error): NonNullable { 176 | return this.isNone() 177 | ? ((): never => err instanceof Error ? ((): never => { throw err })() : ((): never => { throw new Error() })())() 178 | : this.value as NonNullable 179 | } 180 | 181 | public tap(obj: Partial>): void { 182 | this.isNone() 183 | ? typeof obj.none === 'function' && obj.none() 184 | : typeof obj.some === 'function' && obj.some(this.value as NonNullable) 185 | } 186 | 187 | public tapNone(fn: () => void): void { 188 | (this.isNone()) && fn() 189 | } 190 | 191 | public tapSome(fn: (val: NonNullable) => void): void { 192 | (this.isSome()) && fn(this.value as NonNullable) 193 | } 194 | 195 | public tapThru(val: Partial>): IMaybe { 196 | this.tap(val) 197 | return this 198 | } 199 | 200 | public tapThruNone(fn: () => void): IMaybe { 201 | this.tapNone(fn) 202 | return this 203 | } 204 | 205 | public tapThruSome(fn: (val: T) => void): IMaybe { 206 | this.tapSome(fn) 207 | return this 208 | } 209 | 210 | public match(pattern: IMaybePattern): R { 211 | return this.isNone() 212 | ? pattern.none() 213 | : pattern.some(this.value as NonNullable) 214 | } 215 | 216 | public toArray(): ReadonlyArray { 217 | return this.isNone() 218 | ? [] 219 | : Array.isArray(this.value) 220 | ? this.value 221 | : [this.value as NonNullable] 222 | } 223 | 224 | public map(fn: (t: NonNullable) => NonNullable): IMaybe { 225 | return this.isSome() 226 | ? new Maybe(fn(this.value as NonNullable)) 227 | : new Maybe() 228 | } 229 | 230 | public mapTo(t: NonNullable): IMaybe { 231 | return this.isSome() 232 | ? new Maybe(t) 233 | : new Maybe() 234 | } 235 | 236 | public flatMap(fn: (d: NonNullable) => IMaybe): IMaybe { 237 | return this.isNone() ? new Maybe() : fn(this.value as NonNullable) 238 | } 239 | 240 | public flatMapAuto(fn: (d: NonNullable) => R): IMaybe> { 241 | return this.isNone() 242 | ? new Maybe>() 243 | : new Maybe>(fn(this.value as NonNullable) as NonNullable) 244 | } 245 | 246 | public project(fn: (d: NonNullable) => R): IMaybe> { 247 | return this.flatMapAuto(fn) 248 | } 249 | 250 | public filter(fn: (f: NonNullable) => boolean): IMaybe { 251 | return this.isNone() 252 | ? new Maybe() 253 | : fn(this.value as NonNullable) 254 | ? new Maybe(this.value as NonNullable) 255 | : new Maybe() 256 | } 257 | 258 | public apply(maybeFn: IMaybe<(t: NonNullable) => R>): IMaybe> { 259 | return this.flatMap(v => maybeFn.flatMapAuto(f => f(v))) 260 | } 261 | 262 | public toResult(error: E): IResult { 263 | return this 264 | .map>(b => new OkResult(b)) 265 | .valueOr(new FailResult(error)) 266 | } 267 | 268 | public flatMapPromise(fn: (val: NonNullable) => Promise): Promise>> { 269 | if (this.isNone()) { 270 | return Promise.resolve(new Maybe>()) 271 | } 272 | 273 | return fn(this.value as NonNullable) 274 | .then((value: R) => new Maybe>(value as NonNullable)) 275 | .catch(() => new Maybe>()) 276 | } 277 | 278 | public flatMapObservable(fn: (val: NonNullable) => import('rxjs').Observable): Promise>> { 279 | if (this.isNone()) { 280 | return Promise.resolve(new Maybe>()) 281 | } 282 | return import('rxjs').then(({ firstValueFrom, EMPTY, take, map, catchError }) => firstValueFrom( 283 | fn(this.value as NonNullable).pipe( 284 | take(1), 285 | map((value: unknown) => new Maybe>(value as NonNullable)), 286 | catchError(() => EMPTY) 287 | ) 288 | ).then( 289 | (maybeValue: IMaybe>) => maybeValue, 290 | () => new Maybe>() 291 | )) 292 | } 293 | 294 | public flatMapMany(fn: (val: NonNullable) => Promise[]): Promise[]>> { 295 | if (this.isNone()) { 296 | return Promise.resolve(new Maybe[]>()) 297 | } 298 | 299 | return Promise.all(fn(this.value as NonNullable)) 300 | .then((values: R[]) => new Maybe[]>(values as NonNullable[])) 301 | .catch(() => new Maybe[]>()) 302 | } 303 | 304 | public zipWith(...args: unknown[]): IMaybe { 305 | if (this.isNone()) { 306 | return new Maybe() 307 | } 308 | 309 | const fn = args[args.length - 1] as (...values: unknown[]) => NonNullable 310 | const maybes = args.slice(0, -1) as IMaybe[] 311 | 312 | const values: unknown[] = [this.value] 313 | 314 | for (const m of maybes) { 315 | if (m.isNone()) { 316 | return new Maybe() 317 | } 318 | values.push(m.valueOrThrow()) 319 | } 320 | 321 | return new Maybe(fn(...values)) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/reader/reader.spec.ts: -------------------------------------------------------------------------------- 1 | import { reader, readerOf, ask, asks, combine, sequence, traverse } from './reader.factory' 2 | import { IReader } from './reader.interface' 3 | import { Reader } from './reader' 4 | 5 | describe('Reader Monad', () => { 6 | describe('basic operations', () => { 7 | it('should create a Reader with a function', () => { 8 | const greet = reader(ctx => ctx + '_HelloA') 9 | expect(greet.run('Test')).toEqual('Test_HelloA') 10 | }) 11 | 12 | it('should create a new Reader with of', () => { 13 | const greet = reader(ctx => ctx + '_HelloA') 14 | const greet2 = greet.of(ctx => ctx + '_HelloB') 15 | 16 | expect(greet.run('Test')).toEqual('Test_HelloA') 17 | expect(greet2.run('Test')).toEqual('Test_HelloB') 18 | }) 19 | 20 | it('should map the Reader output', () => { 21 | const greet = reader(ctx => ctx + '_HelloA') 22 | const greet2 = greet.map(s => s + '_Mapped123') 23 | 24 | expect(greet.run('Test')).toEqual('Test_HelloA') 25 | expect(greet2.run('Test')).toEqual('Test_HelloA_Mapped123') 26 | }) 27 | 28 | it('should map to a constant value', () => { 29 | const greet = reader(ctx => ctx + '_HelloA') 30 | const greet2 = greet.mapTo('Constant value') 31 | 32 | expect(greet.run('Test')).toEqual('Test_HelloA') 33 | expect(greet2.run('Test')).toEqual('Constant value') 34 | }) 35 | 36 | it('should flatMap to chain Readers', () => { 37 | const greet = (name: string): IReader => reader(ctx => ctx + ', ' + name) 38 | const end = (str: string): IReader => reader(a => a === 'Hello') 39 | .flatMap(isH => isH ? reader(() => str + '!!!') : reader(() => str + '.')) 40 | 41 | expect(greet('Tom').flatMap(end).run('Hello')).toEqual('Hello, Tom!!!') 42 | expect(greet('Jerry').flatMap(end).run('Hi')).toEqual('Hi, Jerry.') 43 | }) 44 | }) 45 | 46 | describe('factory functions', () => { 47 | it('should create a constant Reader with readerOf', () => { 48 | const constReader = readerOf(42) 49 | expect(constReader.run('anything')).toBe(42) 50 | expect(constReader.run({})).toBe(42) 51 | expect(constReader.run(null)).toBe(42) 52 | }) 53 | 54 | it('should create a Reader that returns the environment with ask', () => { 55 | const config = { api: 'https://example.com', timeout: 5000 } 56 | const askReader = ask() 57 | expect(askReader.run(config)).toBe(config) 58 | }) 59 | 60 | it('should create a Reader that extracts a value from the environment with asks', () => { 61 | const config = { api: 'https://example.com', timeout: 5000 } 62 | const getApi = asks(c => c.api) 63 | const getTimeout = asks(c => c.timeout) 64 | 65 | expect(getApi.run(config)).toBe('https://example.com') 66 | expect(getTimeout.run(config)).toBe(5000) 67 | }) 68 | }) 69 | 70 | describe('static methods on Reader class', () => { 71 | it('should create a Reader that returns a constant value with of', () => { 72 | const r = Reader.of(42) 73 | expect(r.run('anything')).toBe(42) 74 | }) 75 | 76 | it('should create a Reader that returns the environment with ask', () => { 77 | const r = Reader.ask() 78 | expect(r.run('environment')).toBe('environment') 79 | }) 80 | 81 | it('should create a Reader that extracts a value with asks', () => { 82 | const r = Reader.asks<{value: number}, number>(config => config.value) 83 | expect(r.run({value: 42})).toBe(42) 84 | }) 85 | }) 86 | 87 | describe('new instance methods', () => { 88 | it('should modify the environment with local', () => { 89 | type AppConfig = { 90 | database: { 91 | host: string 92 | port: number 93 | } 94 | } 95 | 96 | // Reader that works with a DatabaseConfig 97 | const getConnectionString = reader<{host: string; port: number}, string>( 98 | db => `postgres://${db.host}:${db.port}/mydb` 99 | ) 100 | 101 | // Make it work with an AppConfig by extracting the database property 102 | const getAppConnectionString = getConnectionString.local( 103 | appConfig => appConfig.database 104 | ) 105 | 106 | const appConfig = { 107 | database: { 108 | host: 'localhost', 109 | port: 5432 110 | } 111 | } 112 | 113 | expect(getAppConnectionString.run(appConfig)).toBe('postgres://localhost:5432/mydb') 114 | }) 115 | 116 | it('should perform side effects with tap', () => { 117 | let sideEffect = '' 118 | 119 | const simpleReader = reader(s => s.toUpperCase()) 120 | .tap(result => { sideEffect = `Processed: ${result}` }) 121 | 122 | expect(simpleReader.run('hello')).toBe('HELLO') 123 | expect(sideEffect).toBe('Processed: HELLO') 124 | }) 125 | 126 | it('should chain Readers with andThen', () => { 127 | let sideEffect = '' 128 | 129 | const first = reader(s => { 130 | sideEffect = `First processed: ${s}` 131 | return s.toUpperCase() 132 | }) 133 | 134 | const second = reader(s => s.length) 135 | 136 | const combined = first.andThen(second) 137 | 138 | expect(combined.run('hello')).toBe(5) // length of 'hello' 139 | expect(sideEffect).toBe('First processed: hello') 140 | }) 141 | 142 | it('should chain Readers with andFinally', () => { 143 | let sideEffect = '' 144 | 145 | const first = reader(s => s.toUpperCase()) 146 | 147 | const second = reader(s => { 148 | sideEffect = `Second processed: ${s}` 149 | }) 150 | 151 | const combined = first.andFinally(second) 152 | 153 | expect(combined.run('hello')).toBe('HELLO') 154 | expect(sideEffect).toBe('Second processed: hello') 155 | }) 156 | 157 | it('should transform using both environment and value with withEnv', () => { 158 | const reader1 = reader(env => env.length) 159 | 160 | const reader2 = reader1.withEnv((env, length) => `${env} has ${length} characters`) 161 | 162 | expect(reader2.run('hello')).toBe('hello has 5 characters') 163 | }) 164 | 165 | it('should filter output values based on a predicate', () => { 166 | const getAge = reader<{age: number}, number>(c => c.age) 167 | 168 | const getValidAge = getAge.filter( 169 | age => age >= 0 && age <= 120, 170 | 0 // Default for invalid ages 171 | ) 172 | 173 | expect(getValidAge.run({age: 30})).toBe(30) 174 | expect(getValidAge.run({age: -10})).toBe(0) 175 | expect(getValidAge.run({age: 150})).toBe(0) 176 | }) 177 | 178 | it('should cache results with memoize', () => { 179 | let computationCount = 0 180 | 181 | const expensiveComputation = reader(n => { 182 | computationCount++ 183 | return `Result for ${n}: ${n * n}` 184 | }).memoize() 185 | 186 | // First call - should compute 187 | expect(expensiveComputation.run(10)).toBe('Result for 10: 100') 188 | expect(computationCount).toBe(1) 189 | 190 | // Second call with same input - should use cache 191 | expect(expensiveComputation.run(10)).toBe('Result for 10: 100') 192 | expect(computationCount).toBe(1) // Still 1, used cache 193 | 194 | // Call with different input - should compute again 195 | expect(expensiveComputation.run(20)).toBe('Result for 20: 400') 196 | expect(computationCount).toBe(2) 197 | }) 198 | 199 | it('should memoize with custom cache key function', () => { 200 | let computationCount = 0 201 | 202 | interface Config { 203 | id: string 204 | timestamp: number 205 | } 206 | 207 | const r = reader(config => { 208 | computationCount++ 209 | return `Processed ${config.id}` 210 | }).memoize(config => config.id) // Only use id for cache key 211 | 212 | const config1 = { id: 'A', timestamp: 1 } 213 | const config2 = { id: 'A', timestamp: 2 } // Same id, different timestamp 214 | 215 | expect(r.run(config1)).toBe('Processed A') 216 | expect(computationCount).toBe(1) 217 | 218 | expect(r.run(config2)).toBe('Processed A') 219 | expect(computationCount).toBe(1) // Still 1, used cache because id is the same 220 | }) 221 | 222 | it('should convert a Reader to a Promise-returning function', async () => { 223 | const r = reader(s => s.length) 224 | const promiseFn = r.toPromise() 225 | 226 | const result = await promiseFn('hello') 227 | expect(result).toBe(5) 228 | }) 229 | 230 | it('should apply multiple transformations with fanout', () => { 231 | const r = reader(s => s) 232 | 233 | const transformed = r.fanout( 234 | s => s.length, 235 | s => s.toUpperCase(), 236 | s => s.charAt(0) 237 | ) 238 | 239 | expect(transformed.run('hello')).toEqual([5, 'HELLO', 'h']) 240 | }) 241 | 242 | it('should combine two readers with zipWith', () => { 243 | const lengthReader = reader(s => s.length) 244 | const upperReader = reader(s => s.toUpperCase()) 245 | 246 | const combined = lengthReader.zipWith( 247 | upperReader, 248 | (len, upper) => `${upper} has length ${len}` 249 | ) 250 | 251 | expect(combined.run('hello')).toBe('HELLO has length 5') 252 | }) 253 | }) 254 | 255 | describe('static Reader functions', () => { 256 | it('should create a sequence of Readers', () => { 257 | // Test with same type for simplicity 258 | const r1 = reader(s => s.toUpperCase()) 259 | const r2 = reader(s => s.toLowerCase()) 260 | 261 | // We need any here since Array> doesn't match the overload 262 | const sequence = Reader.sequence([r1, r2]) 263 | 264 | expect(sequence.run('Hello')).toEqual(['HELLO', 'hello']) 265 | }) 266 | 267 | it('should handle empty sequence', () => { 268 | const emptySequence = Reader.sequence([]) 269 | expect(emptySequence.run('anything')).toEqual([]) 270 | }) 271 | 272 | it('should traverse and combine Readers', () => { 273 | const r1 = reader(s => s.toUpperCase()) 274 | const r2 = reader(s => s.toLowerCase()) 275 | 276 | const traversed = Reader.traverse( 277 | [r1, r2], 278 | (acc: string[], val: string) => [...acc, val], 279 | [] as string[] 280 | ) 281 | 282 | expect(traversed.run('Hello')).toEqual(['HELLO', 'hello']) 283 | }) 284 | 285 | it('should combine multiple Readers using combine', () => { 286 | const r1 = reader(s => s.toUpperCase()) 287 | const r2 = reader(s => s.toLowerCase()) 288 | 289 | const combined = Reader.combine( 290 | [r1, r2], 291 | (upper, lower) => `Upper: ${upper}, Lower: ${lower}` 292 | ) 293 | 294 | expect(combined.run('Hello')).toBe('Upper: HELLO, Lower: hello') 295 | }) 296 | }) 297 | 298 | describe('factory functions coverage', () => { 299 | it('should use traverse factory function', () => { 300 | const r1 = reader(s => s.toUpperCase()) 301 | const r2 = reader(s => s.toLowerCase()) 302 | 303 | const result = traverse( 304 | [r1, r2], 305 | (acc: string[], val: string, index: number) => { 306 | acc[index] = val 307 | return acc 308 | }, 309 | ['', ''] 310 | ) 311 | 312 | expect(result.run('Hello')).toEqual(['HELLO', 'hello']) 313 | }) 314 | 315 | it('should use combine factory function', () => { 316 | const r1 = reader(s => s.toUpperCase()) 317 | const r2 = reader(s => s.toLowerCase()) 318 | 319 | const result = combine( 320 | [r1, r2], 321 | (upper, lower) => `${upper} and ${lower}` 322 | ) 323 | 324 | expect(result.run('Hello')).toBe('HELLO and hello') 325 | }) 326 | 327 | it('should use sequence factory function', () => { 328 | const r1 = reader(s => s.toUpperCase()) 329 | const r2 = reader(s => s.toLowerCase()) 330 | 331 | const result = sequence([r1, r2]) 332 | 333 | expect(result.run('Hello')).toEqual(['HELLO', 'hello']) 334 | }) 335 | }) 336 | }) 337 | -------------------------------------------------------------------------------- /src/result/result.spec.ts: -------------------------------------------------------------------------------- 1 | import { ok, fail, result } from './result.factory' 2 | import { IMaybe, maybe, none, some } from '../maybe/public_api' 3 | 4 | describe('result', () => { 5 | describe('ok', () => { 6 | it('should return true when "isOk" invoked on a success path', () => { 7 | expect(ok(1).isOk()).toEqual(true) 8 | }) 9 | 10 | it('should return false when "isFail" invoked on a success path', () => { 11 | expect(ok(1).isFail()).toEqual(false) 12 | }) 13 | 14 | it('should unwrap', () => { 15 | expect(ok(1).unwrap()).toEqual(1) 16 | expect(ok('Test').unwrap()).toEqual('Test') 17 | }) 18 | 19 | it('should return proper value when "unwrapOr" is applied', () => { 20 | expect(ok(1).unwrapOr(25)).toEqual(1) 21 | expect(ok('Test').unwrapOr('Some Other')).toEqual('Test') 22 | }) 23 | 24 | it('should throw an exception whe "unwrapOrFail" called on an ok value', () => { 25 | expect(() => { 26 | ok(1).unwrapFail() 27 | }).toThrowError() 28 | }) 29 | 30 | it('should ...', () => { 31 | const _sut = ok('Test') 32 | .maybeOk() 33 | .map(b => b) 34 | .valueOr('Some Other') 35 | 36 | expect(_sut).toEqual('Test') 37 | }) 38 | 39 | it('should ...', () => { 40 | const _sut = ok('Test') 41 | .maybeFail() 42 | .valueOrUndefined() 43 | 44 | expect(_sut).toEqual(undefined) 45 | }) 46 | 47 | it('should map function', () => { 48 | const sut = ok(1) 49 | .map(b => b.toString()) 50 | .unwrap() 51 | expect(sut).toEqual('1') 52 | }) 53 | 54 | it('should not mapFail', () => { 55 | const sut = ok(1) 56 | .mapFail(() => '') 57 | .unwrap() 58 | expect(sut).toEqual(1) 59 | }) 60 | 61 | it('should flatMap', () => { 62 | const sut = ok(1) 63 | .flatMap(a => ok(a.toString())) 64 | .unwrap() 65 | 66 | expect(sut).toEqual('1') 67 | }) 68 | 69 | it('should match', () => { 70 | const sut = ok(1) 71 | .match({ 72 | fail: () => 2, 73 | ok: val => val 74 | }) 75 | 76 | expect(sut).toEqual(1) 77 | }) 78 | }) 79 | 80 | describe('fail', () => { 81 | it('should return false when "isOk" invoked', () => { 82 | expect(fail(1).isOk()).toEqual(false) 83 | }) 84 | 85 | it('should return true when "isFail" invoked', () => { 86 | expect(fail(1).isFail()).toEqual(true) 87 | }) 88 | 89 | it('should return empty maybe when "maybeOk" is invoked', () => { 90 | const sut = fail('Test') 91 | .maybeOk() 92 | .valueOr('Some Other1') 93 | 94 | expect(sut).toEqual('Some Other1') 95 | }) 96 | 97 | it('should return fail object when "maybeFail" is invoked', () => { 98 | const sut = fail('Test') 99 | .maybeFail() 100 | .valueOr('Some Other2') 101 | 102 | expect(sut).toEqual('Test') 103 | }) 104 | 105 | it('should throw an exception on "unwrap"', () => { 106 | expect(() => { fail(1).unwrap() }).toThrowError() 107 | }) 108 | 109 | it('should return fail object on "unwrapFail"', () => { 110 | expect(fail('123').unwrapFail()).toEqual('123') 111 | }) 112 | 113 | it('should return input object on "unwrapOr"', () => { 114 | expect(fail('123').unwrapOr('456')).toEqual('456') 115 | }) 116 | 117 | it('should not map', () => { 118 | const sut = fail(1) 119 | .map(b => b.toString()) 120 | .unwrapFail() 121 | expect(sut).toEqual(1) 122 | }) 123 | 124 | it('should mapFail', () => { 125 | const sut = fail(1) 126 | .mapFail(b => b.toString()) 127 | .unwrapFail() 128 | expect(sut).toEqual('1') 129 | }) 130 | 131 | it('should not flatMap', () => { 132 | const sut = fail(1) 133 | .flatMap(a => ok(a.toString())) 134 | .unwrapFail() 135 | 136 | expect(sut).toEqual(1) 137 | }) 138 | 139 | it('should match', () => { 140 | const sut = fail(1) 141 | .match({ 142 | fail: () => 2, 143 | ok: val => val 144 | }) 145 | 146 | expect(sut).toEqual(2) 147 | }) 148 | }) 149 | 150 | describe('result', () => { 151 | it('should return failure when predicate yields false', () => { 152 | const sut = result(() => 1 + 1 === 3, true, 'FAILURE!') 153 | expect(sut.isFail()).toEqual(true) 154 | }) 155 | 156 | it('should return ok when predicate yields true', () => { 157 | const sut = result(() => 1 + 1 === 2, true, 'FAILURE!') 158 | expect(sut.isOk()).toEqual(true) 159 | }) 160 | 161 | it('should return fail when predicate yields false', () => { 162 | const sut = result(() => 1 + 1 === 1, true, 'FAILURE!') 163 | expect(sut.isFail()).toEqual(true) 164 | }) 165 | }) 166 | 167 | describe('toFailIfExists', () => { 168 | it('should toFailWhenOk', () => { 169 | const sut = ok(1) 170 | .map(a => a + 2) 171 | .toFailWhenOk(a => new Error(`only have ${a}`)) 172 | 173 | expect(sut.isFail()).toEqual(true) 174 | expect(sut.unwrapFail()).toBeInstanceOf(Error) 175 | expect(sut.unwrapFail().message).toEqual('only have 3') 176 | }) 177 | 178 | it('should toFailWhenOkFrom from fail', () => { 179 | const sut = fail(new Error('started as error')) 180 | .map(a => a + 2) 181 | .toFailWhenOkFrom(new Error('ended as an error')) 182 | 183 | expect(sut.isFail()).toEqual(true) 184 | expect(sut.unwrapFail()).toBeInstanceOf(Error) 185 | expect(sut.unwrapFail().message).toEqual('ended as an error') 186 | }) 187 | 188 | it('should toFailWhenOk from fail', () => { 189 | const sut = fail(new Error('started as error')) 190 | .map(a => a + 2) 191 | .toFailWhenOk(a => new Error(`ended as an error ${a}`)) 192 | 193 | expect(sut.isFail()).toEqual(true) 194 | expect(sut.unwrapFail()).toBeInstanceOf(Error) 195 | expect(sut.unwrapFail().message).toEqual('started as error') 196 | }) 197 | 198 | it('should toFailWhenOkFrom', () => { 199 | const sut = ok(1) 200 | .map(a => a + 2) 201 | .toFailWhenOkFrom(new Error('error msg')) 202 | 203 | expect(sut.isFail()).toEqual(true) 204 | expect(sut.unwrapFail()).toBeInstanceOf(Error) 205 | expect(sut.unwrapFail().message).toEqual('error msg') 206 | }) 207 | }) 208 | 209 | describe('tap', () => { 210 | it('should tap.ok', done => { 211 | const sut = ok(1) 212 | 213 | sut.tap({ 214 | ok: num => { 215 | expect(num).toEqual(1) 216 | done() 217 | }, 218 | fail: done 219 | }) 220 | }) 221 | 222 | it('should tap.ok', done => { 223 | const sut = fail('failed') 224 | 225 | sut.tap({ 226 | fail: str => { 227 | expect(str).toEqual('failed') 228 | done() 229 | }, 230 | ok: done 231 | }) 232 | }) 233 | 234 | it('should tapOk', done => { 235 | const sut = ok(1) 236 | 237 | sut.tapOk(num => { 238 | expect(num).toEqual(1) 239 | done() 240 | }) 241 | 242 | sut.tapFail(() => { 243 | expect(true).toBeFalsy() 244 | done() 245 | }) 246 | }) 247 | 248 | it('should tapFail', done => { 249 | const sut = fail('failed') 250 | 251 | sut.tapFail(err => { 252 | expect(err).toEqual('failed') 253 | done() 254 | }) 255 | 256 | sut.tapOk(() => { 257 | expect(true).toBeFalsy() 258 | done() 259 | }) 260 | }) 261 | 262 | it('should tapThru', done => { 263 | const result = ok(1) 264 | 265 | let sideEffect = 0 266 | 267 | const sut = result.tapOkThru(v => { 268 | sideEffect = v 269 | }).map(a => a + 2) 270 | 271 | expect(sut.unwrap()).toEqual(3) 272 | expect(sideEffect).toEqual(1) 273 | 274 | done() 275 | }) 276 | 277 | it('should tapThru failed side', done => { 278 | const result = fail('failed') 279 | 280 | let sideEffect = 0 281 | 282 | const sut = result.tapOkThru(v => { 283 | sideEffect = v 284 | }).map(a => a + 2) 285 | 286 | expect(sut.unwrapFail()).toEqual('failed') 287 | expect(sideEffect).toEqual(0) 288 | 289 | done() 290 | }) 291 | 292 | it('should tapFailThru', done => { 293 | const result = fail('failed') 294 | 295 | let sideEffect = '' 296 | 297 | const sut = result.tapFailThru(v => { 298 | sideEffect = v + ' inside' 299 | }).map(a => a + 2) 300 | 301 | expect(sut.unwrapFail()).toEqual('failed') 302 | expect(sideEffect).toEqual('failed inside') 303 | 304 | done() 305 | }) 306 | 307 | it('should tapFailThru Ok side', done => { 308 | const result = ok(1) 309 | 310 | let sideEffect = '' 311 | 312 | const sut = result.tapFailThru(v => { 313 | sideEffect = v + ' inside' 314 | }).map(a => a + 2) 315 | 316 | expect(sut.unwrap()).toEqual(3) 317 | expect(sideEffect).toEqual('') 318 | 319 | done() 320 | }) 321 | 322 | it('should tapThru', done => { 323 | const result = ok(1) 324 | 325 | let sideEffect = 0 326 | 327 | const sut = result.tapThru({ 328 | ok: v => { 329 | sideEffect = v 330 | } 331 | }).map(a => a + 2) 332 | 333 | expect(sut.unwrap()).toEqual(3) 334 | expect(sideEffect).toEqual(1) 335 | 336 | done() 337 | }) 338 | 339 | it('should tapThru', done => { 340 | const result = ok(1) 341 | 342 | let sideEffect = 0 343 | 344 | const sut = result.tapThru({ 345 | ok: v => { 346 | sideEffect = v 347 | }, 348 | fail: f => { 349 | sideEffect = +f 350 | } 351 | }).map(a => a + 2) 352 | 353 | expect(sut.unwrap()).toEqual(3) 354 | expect(sideEffect).toEqual(1) 355 | 356 | done() 357 | }) 358 | 359 | it('should tapThru', done => { 360 | const result = fail('failed') 361 | 362 | let sideEffect = '' 363 | 364 | const sut = result.tapThru({ 365 | ok: v => { 366 | sideEffect = v + '' 367 | }, 368 | fail: f => { 369 | sideEffect = f + ' in here' 370 | } 371 | }).map(a => a + 2) 372 | 373 | expect(sut.unwrapFail()).toEqual('failed') 374 | expect(sideEffect).toEqual('failed in here') 375 | 376 | done() 377 | }) 378 | }) 379 | 380 | describe('flatMapMaybe', () => { 381 | it('should return Ok with value when Result is Ok and Maybe is Some', () => { 382 | const okResult = ok(5) 383 | const res = okResult.flatMapMaybe((val) => maybe(val * 2), 'No value found') 384 | 385 | expect(res.isOk()).toEqual(true) 386 | expect(res.unwrap()).toEqual(10) 387 | }) 388 | 389 | it('should return Fail with error when Result is Ok but Maybe is None', () => { 390 | const okResult = ok(5) 391 | const res = okResult.flatMapMaybe(() => none(), 'No value found') 392 | expect(res.isFail()).toEqual(true) 393 | expect(res.unwrapFail()).toEqual('No value found') 394 | }) 395 | 396 | it('should return Fail with original error when Result is Fail', () => { 397 | const failResult = fail('Original error') 398 | const res = failResult.flatMapMaybe((val: number) => maybe(val * 2), 'No value found') 399 | 400 | expect(res.isFail()).toEqual(true) 401 | expect(res.unwrapFail()).toEqual('Original error') 402 | }) 403 | 404 | it('should ', () => { 405 | const okResult = ok<{ result: number; data: IMaybe<{ zeta: number }> }, Error>({ result: 1, data: some({ zeta: 2 }) }) 406 | const res = okResult.flatMapMaybe((a) => a.data, new Error('No value found')) 407 | 408 | expect(res.isFail()).toEqual(false) 409 | expect(res.unwrap()).toEqual({ zeta: 2 }) 410 | }) 411 | 412 | it('should work with complex object properties', () => { 413 | type User = { 414 | id: number 415 | profile?: { 416 | name: string 417 | } 418 | } 419 | 420 | // User with profile 421 | const userWithProfile: User = { id: 1, profile: { name: 'John' } } 422 | const okResult = ok(userWithProfile) 423 | 424 | const res1 = okResult.flatMapMaybe( 425 | (user: User) => maybe(user.profile), 426 | 'Profile not found' 427 | ) 428 | 429 | expect(res1.isOk()).toEqual(true) 430 | expect(res1.unwrap().name).toEqual('John') 431 | 432 | // User without profile 433 | const userWithoutProfile: User = { id: 2 } 434 | const okResult2 = ok(userWithoutProfile) 435 | 436 | const res2 = okResult2.flatMapMaybe( 437 | (user: User) => maybe(user.profile), 438 | 'Profile not found' 439 | ) 440 | 441 | expect(res2.isFail()).toEqual(true) 442 | expect(res2.unwrapFail()).toEqual('Profile not found') 443 | }) 444 | }) 445 | }) 446 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

📚 typescript-monads

2 |

Better TypeScript Control Flow

3 |
8 |

9 | 10 | semantic-release 11 | 12 | 13 | npm latest version 14 | 15 |

16 | 17 | **typescript-monads** helps you write safer code by using abstractions over messy control flow and state. 18 | 19 | # Installation 20 | You can use this library in the browser, node, or a bundler 21 | 22 | ## Node or as a module 23 | ```bash 24 | npm install typescript-monads 25 | ``` 26 | 27 | ## Browser 28 | ```html 29 | 30 | 31 | 32 | 33 | 34 | ``` 35 | 36 | ```js 37 | var someRemoteValue; 38 | typescriptMonads.maybe(someRemoteValue).tapSome(console.log) 39 | ``` 40 | 41 | # Example Usage 42 | 43 | * [Maybe](#maybe) 44 | * [List](#list) 45 | * [Either](#either) 46 | * [Reader](#reader) 47 | * [Result](#result) 48 | * [State](#state) 49 | * [Logger](#logger) 50 | 51 | # Maybe 52 | 53 | The `Maybe` monad represents values that may or may not exist. It's a safe way to handle potentially null or undefined values without resorting to null checks throughout your code. 54 | 55 | ```typescript 56 | import { maybe, none } from 'typescript-monads' 57 | 58 | // Creating Maybe instances 59 | const someValue = maybe(42) // Maybe with a value 60 | const noValue = maybe(null) // Maybe with no value (None) 61 | const alsoNoValue = none() // Explicitly create a None 62 | 63 | // Safe value access 64 | someValue.valueOr(0) // 42 65 | noValue.valueOr(0) // 0 66 | someValue.valueOrCompute(() => expensiveCalculation()) // 42 (computation skipped) 67 | noValue.valueOrCompute(() => expensiveCalculation()) // result of computation 68 | 69 | // Conditional execution with pattern matching 70 | someValue.match({ 71 | some: val => console.log(`Got a value: ${val}`), 72 | none: () => console.log('No value present') 73 | }) // logs: "Got a value: 42" 74 | 75 | // Side effects with tap 76 | someValue.tap({ 77 | some: val => console.log(`Got ${val}`), 78 | none: () => console.log('Nothing to see') 79 | }) 80 | 81 | // Conditional side effects 82 | someValue.tapSome(val => console.log(`Got ${val}`)) 83 | noValue.tapNone(() => console.log('Nothing here')) 84 | 85 | // Chaining operations (only executed for Some values) 86 | maybe(5) 87 | .map(n => n * 2) // maybe(10) 88 | .filter(n => n > 5) // maybe(10) 89 | .flatMap(n => maybe(n + 1)) // maybe(11) 90 | 91 | // Transforming to other types 92 | maybe(5).toResult('No value found') // Ok(5) 93 | maybe(null).toResult('No value found') // Fail('No value found') 94 | 95 | // Working with RxJS (with rxjs optional dependency) 96 | import { maybeToObservable } from 'typescript-monads' 97 | maybeToObservable(maybe(5)) // Observable that emits 5 and completes 98 | maybeToObservable(none()) // Observable that completes without emitting 99 | ``` 100 | 101 | ## List 102 | 103 | The `List` monad is a lazily evaluated collection with chainable operations. It provides many of the common list processing operations found in functional programming languages. 104 | 105 | ```typescript 106 | import { List } from 'typescript-monads' 107 | 108 | // Creating Lists 109 | const fromValues = List.of(1, 2, 3, 4, 5) 110 | const fromIterable = List.from([1, 2, 3, 4, 5]) 111 | const numbersFromRange = List.range(1, 10) // 1 to 10 112 | const infiniteNumbers = List.integers() // All integers (use with take!) 113 | const empty = List.empty() 114 | 115 | // Basic operations 116 | fromValues.toArray() // [1, 2, 3, 4, 5] 117 | fromValues.headOrUndefined() // 1 118 | fromValues.headOr(0) // 1 119 | empty.headOr(0) // 0 120 | 121 | // Transformations 122 | fromValues 123 | .map(n => n * 2) // [2, 4, 6, 8, 10] 124 | .filter(n => n > 5) // [6, 8, 10] 125 | .take(2) // [6, 8] 126 | .drop(1) // [8] 127 | 128 | // LINQ-style operations 129 | fromValues.sum() // 15 130 | fromValues.all(n => n > 0) // true 131 | fromValues.any(n => n % 2 === 0) // true 132 | fromValues.where(n => n % 2 === 0) // [2, 4] 133 | 134 | // Conversion 135 | fromValues.toDictionary() // { 0: 1, 1: 2, 2: 3, 3: 4, 4: 5 } 136 | const users = List.of( 137 | { id: 'a', name: 'Alice' }, 138 | { id: 'b', name: 'Bob' } 139 | ) 140 | users.toDictionary('id') // { 'a': { id: 'a', name: 'Alice' }, 'b': { id: 'b', name: 'Bob' } } 141 | ``` 142 | 143 | ## Either 144 | 145 | The `Either` monad represents values that can be one of two possible types. It's often used to represent a value that can be either a success (Right) or a failure (Left). 146 | 147 | ```typescript 148 | import { either } from 'typescript-monads' 149 | 150 | // Creating Either instances 151 | const rightValue = either(undefined, 42) // Right value 152 | const leftValue = either('error', undefined) // Left value 153 | 154 | // Checking which side is present 155 | rightValue.isRight() // true 156 | rightValue.isLeft() // false 157 | leftValue.isRight() // false 158 | leftValue.isLeft() // true 159 | 160 | // Pattern matching 161 | rightValue.match({ 162 | right: val => `Success: ${val}`, 163 | left: err => `Error: ${err}` 164 | }) // "Success: 42" 165 | 166 | // Side effects with tap 167 | rightValue.tap({ 168 | right: val => console.log(`Got right: ${val}`), 169 | left: err => console.log(`Got left: ${err}`) 170 | }) 171 | 172 | // Transformation (only applies to Right values) 173 | rightValue.map(n => n * 2) // Either with Right(84) 174 | leftValue.map(n => n * 2) // Either with Left('error') unchanged 175 | 176 | // Chaining (flatMap only applies to Right values) 177 | rightValue.flatMap(n => either(undefined, n + 10)) // Either with Right(52) 178 | leftValue.flatMap(n => either(undefined, n + 10)) // Either with Left('error') unchanged 179 | ``` 180 | 181 | ## Reader 182 | 183 | The `Reader` monad represents a computation that depends on some external configuration or environment. It's useful for dependency injection. 184 | 185 | ```typescript 186 | import { reader } from 'typescript-monads' 187 | 188 | // Define a configuration type 189 | interface Config { 190 | apiUrl: string 191 | apiKey: string 192 | } 193 | 194 | // Create readers that depend on this configuration 195 | const getApiUrl = reader(config => config.apiUrl) 196 | const getApiKey = reader(config => config.apiKey) 197 | 198 | // Compose readers to build more complex operations 199 | const getAuthHeader = getApiKey.map(key => `Bearer ${key}`) 200 | 201 | // Create a reader for making an API request 202 | const fetchData = reader>(config => { 203 | return fetch(`${config.apiUrl}/data`, { 204 | headers: { 205 | 'Authorization': `Bearer ${config.apiKey}` 206 | } 207 | }) 208 | }) 209 | 210 | // Execute the reader with a specific configuration 211 | const config: Config = { 212 | apiUrl: 'https://api.example.com', 213 | apiKey: 'secret-key-123' 214 | } 215 | 216 | const apiUrl = getApiUrl.run(config) // 'https://api.example.com' 217 | const authHeader = getAuthHeader.run(config) // 'Bearer secret-key-123' 218 | fetchData.run(config).then(response => { 219 | // Handle API response 220 | }) 221 | ``` 222 | 223 | ## Result 224 | 225 | The `Result` monad represents operations that can either succeed with a value or fail with an error. It's similar to Either but with more specific semantics for success/failure. 226 | 227 | ```typescript 228 | import { ok, fail, catchResult } from 'typescript-monads' 229 | 230 | // Creating Result instances 231 | const success = ok(42) // Success with value 42 232 | const failure = fail('error') // Failure with error 'error' 233 | 234 | // Safely catching exceptions 235 | const result = catchResult( 236 | () => { 237 | // Code that might throw 238 | if (Math.random() > 0.5) { 239 | throw new Error('Failed') 240 | } 241 | return 42 242 | } 243 | ) 244 | 245 | // Checking result type 246 | success.isOk() // true 247 | success.isFail() // false 248 | failure.isOk() // false 249 | failure.isFail() // true 250 | 251 | // Extracting values safely 252 | success.unwrapOr(0) // 42 253 | failure.unwrapOr(0) // 0 254 | success.unwrap() // 42 255 | // failure.unwrap() // Throws error 256 | 257 | // Convert to Maybe 258 | success.maybeOk() // Maybe with Some(42) 259 | failure.maybeOk() // Maybe with None 260 | success.maybeFail() // Maybe with None 261 | failure.maybeFail() // Maybe with Some('error') 262 | 263 | // Pattern matching 264 | success.match({ 265 | ok: val => `Success: ${val}`, 266 | fail: err => `Error: ${err}` 267 | }) // "Success: 42" 268 | 269 | // Transformations 270 | success.map(n => n * 2) // Ok(84) 271 | failure.map(n => n * 2) // Fail('error') unchanged 272 | failure.mapFail(e => `${e}!`) // Fail('error!') 273 | 274 | // Chaining 275 | success.flatMap(n => ok(n + 10)) // Ok(52) 276 | failure.flatMap(n => ok(n + 10)) // Fail('error') unchanged 277 | 278 | // Side effects 279 | success.tap({ 280 | ok: val => console.log(`Success: ${val}`), 281 | fail: err => console.log(`Error: ${err}`) 282 | }) 283 | success.tapOk(val => console.log(`Success: ${val}`)) 284 | failure.tapFail(err => console.log(`Error: ${err}`)) 285 | 286 | // Chaining with side effects 287 | success 288 | .tapOkThru(val => console.log(`Success: ${val}`)) 289 | .map(n => n * 2) 290 | 291 | // Converting to promises 292 | import { resultToPromise } from 'typescript-monads' 293 | resultToPromise(success) // Promise that resolves to 42 294 | resultToPromise(failure) // Promise that rejects with 'error' 295 | ``` 296 | 297 | ## State 298 | 299 | The `State` monad represents computations that can read and transform state. It's useful for threading state through a series of operations. 300 | 301 | ```typescript 302 | import { state } from 'typescript-monads' 303 | 304 | // Define a state type 305 | interface AppState { 306 | count: number 307 | name: string 308 | } 309 | 310 | // Initial state 311 | const initialState: AppState = { 312 | count: 0, 313 | name: '' 314 | } 315 | 316 | // Create operations that work with the state 317 | const incrementCount = state(s => 318 | [{ ...s, count: s.count + 1 }, s.count + 1] 319 | ) 320 | 321 | const setName = (name: string) => state(s => 322 | [{ ...s, name }, undefined] 323 | ) 324 | 325 | const getCount = state(s => [s, s.count]) 326 | 327 | // Compose operations 328 | const operation = incrementCount 329 | .flatMap(() => setName('Alice')) 330 | .flatMap(() => getCount) 331 | 332 | // Run the state operation 333 | const result = operation.run(initialState) 334 | console.log(result.state) // { count: 1, name: 'Alice' } 335 | console.log(result.value) // 1 336 | ``` 337 | 338 | ## Logger 339 | 340 | The `Logger` monad lets you collect logs during a computation. It's useful for debugging or creating audit trails. 341 | 342 | ```typescript 343 | import { logger, tell } from 'typescript-monads' 344 | 345 | // Create a logger with initial logs and value 346 | const initialLogger = logger(['Starting process'], 0) 347 | 348 | // Add logs and transform value 349 | const result = initialLogger 350 | .flatMap(val => { 351 | return logger(['Incrementing value'], val + 1) 352 | }) 353 | .flatMap(val => { 354 | return logger(['Doubling value'], val * 2) 355 | }) 356 | 357 | // Extract all logs and final value 358 | result.runUsing(({ logs, value }) => { 359 | console.log('Logs:', logs) // ['Starting process', 'Incrementing value', 'Doubling value'] 360 | console.log('Final value:', value) // 2 361 | }) 362 | 363 | // Start with a single log entry 364 | const startLogger = tell('Beginning') 365 | 366 | // Add a value to the logger 367 | const withValue = logger.startWith('Starting with value:', 42) 368 | 369 | // Extract results using pattern matching 370 | const output = result.runUsing(({ logs, value }) => { 371 | return { 372 | history: logs.join('\n'), 373 | result: value 374 | } 375 | }) 376 | ``` 377 | 378 | # Integration with Other Libraries 379 | 380 | ## RxJS Integration 381 | 382 | This library offers RxJS integration with the `Maybe` and `Result` monads: 383 | 384 | ```typescript 385 | import { maybeToObservable } from 'typescript-monads' 386 | import { resultToObservable } from 'typescript-monads' 387 | import { of } from 'rxjs' 388 | import { flatMap } from 'rxjs/operators' 389 | 390 | // Convert Maybe to Observable 391 | of(maybe(5)).pipe( 392 | flatMap(maybeToObservable) 393 | ).subscribe(val => console.log(val)) // logs 5 and completes 394 | 395 | // Convert Result to Observable 396 | of(ok(42)).pipe( 397 | flatMap(resultToObservable) 398 | ).subscribe( 399 | val => console.log(`Success: ${val}`), 400 | err => console.error(`Error: ${err}`) 401 | ) 402 | ``` 403 | 404 | ## Promise Integration 405 | 406 | You can convert `Result` monads to promises: 407 | 408 | ```typescript 409 | import { resultToPromise } from 'typescript-monads' 410 | 411 | // Convert Result to Promise 412 | resultToPromise(ok(42)) 413 | .then(val => console.log(`Success: ${val}`)) 414 | .catch(err => console.error(`Error: ${err}`)) 415 | 416 | // Catch exceptions and convert to Result 417 | async function fetchData() { 418 | try { 419 | const response = await fetch('https://api.example.com/data') 420 | if (!response.ok) { 421 | throw new Error(`HTTP error ${response.status}`) 422 | } 423 | return ok(await response.json()) 424 | } catch (error) { 425 | return fail(error) 426 | } 427 | } 428 | ``` 429 | 430 | # Contributing 431 | 432 | Contributions are welcome! Please feel free to submit a Pull Request. 433 | 434 | # License 435 | 436 | MIT -------------------------------------------------------------------------------- /src/maybe/maybe.interface.ts: -------------------------------------------------------------------------------- 1 | import { IMonad } from '../monad/monad.interface' 2 | import { IResult } from '../result/result.interface' 3 | 4 | /** 5 | * Defines a pattern matching contract for unwrapping Maybe objects. 6 | * 7 | * This interface provides separate handlers for the Some and None states, 8 | * enabling exhaustive case analysis when working with Maybe values. 9 | * 10 | * @typeParam TIn - The type of value contained in the Maybe 11 | * @typeParam TOut - The type of value returned by the pattern matching functions 12 | */ 13 | export interface IMaybePattern { 14 | /** 15 | * Function to handle when a value exists (Some case). 16 | * 17 | * @param val - The non-null, non-undefined value contained in the Maybe 18 | * @returns A value of type TOut 19 | */ 20 | some(val: NonNullable): TOut 21 | 22 | /** 23 | * Function to handle when a value is null or undefined (None case). 24 | * 25 | * @returns A value of type TOut 26 | */ 27 | none(): TOut 28 | } 29 | 30 | /** 31 | * A monad that represents optional values, handling the possibility of undefined or null values. 32 | * 33 | * The Maybe monad provides a safe way to work with values that might not exist without 34 | * explicitly checking for null/undefined at every step. It encapsulates common patterns 35 | * for handling optional values and enables fluent, chainable operations. 36 | * 37 | * There are two states of Maybe: 38 | * - Some: Contains a non-null, non-undefined value 39 | * - None: Represents the absence of a value (null or undefined) 40 | * 41 | * @typeParam T - The type of the value that may be present 42 | */ 43 | export interface IMaybe extends IMonad { 44 | 45 | /** 46 | * Creates a new Maybe instance containing the provided value. 47 | * 48 | * @param x - The value to wrap in a Maybe 49 | * @returns A new Maybe containing the value 50 | * 51 | * @example 52 | * const maybeNumber = maybe.of(42); 53 | * // Creates a Some Maybe containing 42 54 | */ 55 | of(x: T): IMaybe 56 | 57 | /** 58 | * Unwraps a Maybe, returning the contained value if present, or a default value if empty. 59 | * 60 | * @param val - The default value to return if this Maybe is None 61 | * @returns The contained value if this Maybe is Some, otherwise the provided default value 62 | * 63 | * @example 64 | * const value = maybe(user.email).valueOr('no-email@example.com'); 65 | * // Returns the user's email if it exists, otherwise returns the default string 66 | */ 67 | valueOr(val: NonNullable): T 68 | 69 | /** 70 | * Unwraps a Maybe, returning the contained value if present, or undefined if empty. 71 | * 72 | * @returns The contained value if this Maybe is Some, otherwise undefined 73 | * 74 | * @example 75 | * const email = maybe(user.email).valueOrUndefined(); 76 | * // Returns the email if it exists, otherwise undefined 77 | * 78 | * // Useful for optional chaining compatibility 79 | * const optionalValue = maybe(obj.deep.nested.property).valueOrUndefined(); 80 | */ 81 | valueOrUndefined(): T | undefined 82 | 83 | /** 84 | * Unwraps a Maybe, returning the contained value if present, or null if empty. 85 | * 86 | * @returns The contained value if this Maybe is Some, otherwise null 87 | * 88 | * @example 89 | * const email = maybe(user.email).valueOrNull(); 90 | * // Returns the email if it exists, otherwise null 91 | */ 92 | valueOrNull(): T | null 93 | 94 | /** 95 | * Converts a Maybe to a readonly array containing either the value or nothing. 96 | * 97 | * @returns An array with the contained value if this Maybe is Some, otherwise an empty array 98 | * 99 | * @example 100 | * const emails = maybe(user.email).toArray(); 101 | * // Returns [email] if email exists, otherwise [] 102 | * 103 | * // Useful for functional operations on arrays 104 | * const allEmails = users 105 | * .map(user => maybe(user.email).toArray()) 106 | * .flat(); 107 | * // Creates an array of only the non-null/undefined emails 108 | */ 109 | toArray(): ReadonlyArray 110 | 111 | /** 112 | * Unwraps a Maybe with a lazily computed default value. 113 | * 114 | * Similar to valueOr, but the default value is computed only when needed, 115 | * which can be more efficient when the default is expensive to compute. 116 | * 117 | * @param f - A function that returns the default value if this Maybe is None 118 | * @returns The contained value if this Maybe is Some, otherwise the result of calling f 119 | * 120 | * @example 121 | * const cachedData = maybe(cache.get(key)) 122 | * .valueOrCompute(() => { 123 | * const data = expensiveOperation(); 124 | * cache.set(key, data); 125 | * return data; 126 | * }); 127 | * // Only performs the expensive operation if the cache is empty 128 | */ 129 | valueOrCompute(f: () => NonNullable): T 130 | 131 | /** 132 | * Unwraps a Maybe, returning the contained value if present, or throwing an error with the specified message. 133 | * 134 | * @param msg - Optional custom error message to use when throwing an error 135 | * @returns The contained value if this Maybe is Some 136 | * @throws Error with the provided message (or a default message) if this Maybe is None 137 | * 138 | * @example 139 | * const email = maybe(user.email).valueOrThrow('Email is required'); 140 | * // Returns the email if it exists, otherwise throws an Error with the message "Email is required" 141 | */ 142 | valueOrThrow(msg?: string): T 143 | 144 | /** 145 | * Unwraps a Maybe, returning the contained value if present, or throwing the specified error. 146 | * 147 | * @param err - Optional custom error instance to throw 148 | * @returns The contained value if this Maybe is Some 149 | * @throws The provided Error (or a default Error) if this Maybe is None 150 | * 151 | * @example 152 | * const email = maybe(user.email).valueOrThrowErr(new ValidationError('Email is required')); 153 | * // Returns the email if it exists, otherwise throws the ValidationError 154 | */ 155 | valueOrThrowErr(err?: Error): T 156 | 157 | /** 158 | * Executes side-effect functions based on the state of the Maybe without changing the Maybe. 159 | * 160 | * This is useful for logging, debugging, or performing other side effects 161 | * without affecting the Maybe chain. 162 | * 163 | * @param val - An object with some/none functions to execute based on the Maybe state 164 | * 165 | * @example 166 | * maybe(user.email) 167 | * .tap({ 168 | * some: email => console.log(`Found email: ${email}`), 169 | * none: () => console.log('No email found') 170 | * }); 171 | * // Logs appropriate message but doesn't change the Maybe value 172 | */ 173 | tap(val: Partial>): void 174 | 175 | /** 176 | * Executes a side-effect function when the Maybe is None. 177 | * 178 | * @param f - Function to execute if this Maybe is None 179 | * 180 | * @example 181 | * maybe(user.email) 182 | * .tapNone(() => analytics.track('Missing Email')); 183 | * // Tracks analytics event only if email is missing 184 | */ 185 | tapNone(f: () => void): void 186 | 187 | /** 188 | * Executes a side-effect function when the Maybe is Some. 189 | * 190 | * @param f - Function to execute with the value if this Maybe is Some 191 | * 192 | * @example 193 | * maybe(user.email) 194 | * .tapSome(email => console.log(`Working with email: ${email}`)); 195 | * // Logs message only if email exists 196 | */ 197 | tapSome(f: (val: T) => void): void 198 | 199 | /** 200 | * Executes side-effect functions based on the state of the Maybe and returns the original Maybe. 201 | * 202 | * Similar to tap, but returns the Maybe to enable further chaining. 203 | * 204 | * @param val - An object with some/none functions to execute based on the Maybe state 205 | * @returns This Maybe unchanged, allowing for continued chaining 206 | * 207 | * @example 208 | * const processedEmail = maybe(user.email) 209 | * .tapThru({ 210 | * some: email => console.log(`Found email: ${email}`), 211 | * none: () => console.log('No email found') 212 | * }) 213 | * .map(email => email.toLowerCase()); 214 | * // Logs appropriate message and continues the chain 215 | */ 216 | tapThru(val: Partial>): IMaybe 217 | 218 | /** 219 | * Executes a side-effect function when the Maybe is None and returns the original Maybe. 220 | * 221 | * @param f - Function to execute if this Maybe is None 222 | * @returns This Maybe unchanged, allowing for continued chaining 223 | * 224 | * @example 225 | * const processedEmail = maybe(user.email) 226 | * .tapThruNone(() => analytics.track('Missing Email')) 227 | * .valueOr('default@example.com'); 228 | * // Tracks analytics event if email is missing and continues the chain 229 | */ 230 | tapThruNone(f: () => void): IMaybe 231 | 232 | /** 233 | * Executes a side-effect function when the Maybe is Some and returns the original Maybe. 234 | * 235 | * @param f - Function to execute with the value if this Maybe is Some 236 | * @returns This Maybe unchanged, allowing for continued chaining 237 | * 238 | * @example 239 | * const processedEmail = maybe(user.email) 240 | * .tapThruSome(email => analytics.track('Email Found', { email })) 241 | * .map(email => email.toLowerCase()); 242 | * // Tracks analytics event if email exists and continues the chain 243 | */ 244 | tapThruSome(f: (val: T) => void): IMaybe 245 | 246 | /** 247 | * Performs pattern matching on the Maybe, applying different functions based on its state. 248 | * 249 | * This is the primary way to exhaustively handle both Some and None cases with different logic. 250 | * 251 | * @typeParam R - The return type of the pattern matching functions 252 | * @param pattern - An object with functions for handling Some and None cases 253 | * @returns The result of applying the appropriate function from the pattern 254 | * 255 | * @example 256 | * const greeting = maybe(user.name).match({ 257 | * some: name => `Hello, ${name}!`, 258 | * none: () => 'Hello, guest!' 259 | * }); 260 | * // Returns personalized greeting if name exists, otherwise generic greeting 261 | */ 262 | match(pattern: IMaybePattern): R 263 | 264 | /** 265 | * Transforms the value inside a Some Maybe using the provided function. 266 | * 267 | * If the Maybe is None, it remains None. This follows the standard functor pattern. 268 | * 269 | * @typeParam R - The type of the transformed value 270 | * @param f - A function to transform the contained value 271 | * @returns A new Maybe containing the transformed value if this Maybe is Some, otherwise None 272 | * 273 | * @example 274 | * const upperEmail = maybe(user.email) 275 | * .map(email => email.toUpperCase()); 276 | * // Contains uppercase email if email exists, otherwise None 277 | */ 278 | map(f: (t: T) => NonNullable): IMaybe 279 | 280 | /** 281 | * Replaces the value inside a Some Maybe with a new value. 282 | * 283 | * If the Maybe is None, it remains None. This is a shorthand for map that ignores the current value. 284 | * 285 | * @typeParam R - The type of the new value 286 | * @param v - The new value to use if this Maybe is Some 287 | * @returns A new Maybe containing the new value if this Maybe is Some, otherwise None 288 | * 289 | * @example 290 | * const defaultEmail = maybe(user.hasEmail) 291 | * .mapTo('default@example.com'); 292 | * // Contains 'default@example.com' if hasEmail is true, otherwise None 293 | */ 294 | mapTo(v: NonNullable): IMaybe 295 | 296 | /** 297 | * Checks if this Maybe contains a value (is in the Some state). 298 | * 299 | * This is a type guard that helps TypeScript narrow the type when used in conditionals. 300 | * 301 | * @returns true if this Maybe is Some, false if it is None 302 | * 303 | * @example 304 | * const maybeEmail = maybe(user.email); 305 | * if (maybeEmail.isSome()) { 306 | * // TypeScript knows maybeEmail has a value here 307 | * sendEmail(maybeEmail.valueOrThrow()); 308 | * } 309 | */ 310 | isSome(): boolean 311 | 312 | /** 313 | * Checks if this Maybe is empty (is in the None state). 314 | * 315 | * This is a type guard that helps TypeScript narrow the type when used in conditionals. 316 | * 317 | * @returns true if this Maybe is None, false if it is Some 318 | * 319 | * @example 320 | * const maybeEmail = maybe(user.email); 321 | * if (maybeEmail.isNone()) { 322 | * // TypeScript knows maybeEmail is None here 323 | * promptForEmail(); 324 | * } 325 | */ 326 | isNone(): boolean 327 | 328 | /** 329 | * Chains Maybe operations by applying a function that returns a new Maybe. 330 | * 331 | * This is the core monadic binding operation (>>=) that allows composing operations 332 | * that might fail. If this Maybe is None, the function is not called. 333 | * 334 | * @typeParam R - The type of value in the new Maybe 335 | * @param f - A function that takes the value from this Maybe and returns a new Maybe 336 | * @returns The result of applying f to the value if this Maybe is Some, otherwise None 337 | * 338 | * @example 339 | * const userCity = maybe(user.profile) 340 | * .flatMap(profile => maybe(profile.address)) 341 | * .flatMap(address => maybe(address.city)); 342 | * // Safely navigates nested optional properties 343 | */ 344 | flatMap(f: (t: T) => IMaybe): IMaybe 345 | 346 | /** 347 | * Chains Maybe operations with automatic wrapping of the result in a Maybe. 348 | * 349 | * Similar to flatMap, but the function returns a raw value that will be 350 | * automatically wrapped in a Maybe. Null/undefined results become None. 351 | * 352 | * @typeParam R - The type of value returned by the function 353 | * @param fn - A function that takes the value from this Maybe and returns a value (or null/undefined) 354 | * @returns A Maybe containing the result of applying fn if non-null/undefined, otherwise None 355 | * 356 | * @example 357 | * const userName = maybe(user) 358 | * .flatMapAuto(u => u.profile?.name); 359 | * // Returns Some(name) if user and profile exist and name is non-null, otherwise None 360 | */ 361 | flatMapAuto(fn: (v: NonNullable) => R): IMaybe> 362 | 363 | /** 364 | * Projects a property or derived value from the contained value. 365 | * 366 | * This is a convenient way to access nested properties without explicit 367 | * mapping. Null/undefined results become None. 368 | * 369 | * @typeParam R - The type of the projected value 370 | * @param fn - A function that extracts a property or computes a value from the contained value 371 | * @returns A Maybe containing the projected value if non-null/undefined, otherwise None 372 | * 373 | * @example 374 | * interface User { 375 | * name: string; 376 | * email: string; 377 | * } 378 | * 379 | * const userName = maybe(user) 380 | * .project(u => u.name); 381 | * // Extracts the name property from user if it exists 382 | */ 383 | project(fn: (d: NonNullable) => R): IMaybe> 384 | 385 | /** 386 | * Filters a Maybe based on a predicate function. 387 | * 388 | * If the predicate returns true, the Maybe remains unchanged. 389 | * If the predicate returns false, the Maybe becomes None. 390 | * 391 | * @param fn - A predicate function that tests the contained value 392 | * @returns This Maybe if it is None or if the predicate returns true, otherwise None 393 | * 394 | * @example 395 | * const validEmail = maybe(user.email) 396 | * .filter(email => email.includes('@')); 397 | * // Contains the email only if it includes @, otherwise None 398 | */ 399 | filter(fn: (t: T) => boolean): IMaybe 400 | 401 | /** 402 | * Applies a wrapped function to the value in this Maybe. 403 | * 404 | * This implements the applicative functor pattern, allowing functions 405 | * wrapped in a Maybe to be applied to values wrapped in a Maybe. 406 | * 407 | * @typeParam R - The return type of the wrapped function 408 | * @param fab - A Maybe containing a function to apply to the value in this Maybe 409 | * @returns A Maybe containing the result of applying the function to the value if both are Some, otherwise None 410 | * 411 | * @example 412 | * const validateEmail = (email: string) => email.includes('@') ? email : null; 413 | * const maybeValidate = maybe(validateEmail); 414 | * const maybeEmail = maybe(user.email); 415 | * 416 | * const validatedEmail = maybeEmail.apply(maybeValidate.map(f => (email: string) => f(email))); 417 | * // Contains the email if both the validation function and email exist and validation passes 418 | */ 419 | apply(fab: IMaybe<(t: T) => R>): IMaybe 420 | 421 | /** 422 | * Converts a Maybe to a Result monad. 423 | * 424 | * This is useful when you need to transition from an optional value (Maybe) 425 | * to an explicit success/failure model (Result) with a specific error. 426 | * 427 | * @typeParam E - The error type for the Result 428 | * @param error - The error value to use if this Maybe is None 429 | * @returns An Ok Result containing the value if this Maybe is Some, otherwise a Fail Result with the error 430 | * 431 | * @example 432 | * const user = maybe(findUser(id)) 433 | * .toResult(new Error('User not found')) 434 | * .map(user => processUser(user)); 435 | * // Converts a Maybe to a Result 436 | */ 437 | toResult(error: E): IResult 438 | 439 | /** 440 | * Chains Maybe operations with a function that returns a Promise. 441 | * 442 | * This allows for seamless integration with asynchronous operations. 443 | * The Promise result is automatically wrapped in a Maybe, with null/undefined 444 | * or rejected promises resulting in None. 445 | * 446 | * Note on resolution preservation: This method preserves both the Maybe context and 447 | * the asynchronous nature of Promises: 448 | * - None values short-circuit (the Promise-returning function is never called) 449 | * - Some values are passed to the function, and its Promise result is processed 450 | * - Promise rejections become None values in the resulting Maybe 451 | * - Promise resolutions become Some values if non-nullish, None otherwise 452 | * 453 | * This approach preserves the monadic semantics while adding asynchronicity. 454 | * 455 | * @typeParam R - The type of the value in the resulting Promise 456 | * @param fn - A function that takes the value from this Maybe and returns a Promise 457 | * @returns A Promise that resolves to a Maybe containing the resolved value 458 | * 459 | * @example 460 | * maybe(userId) 461 | * .flatMapPromise(id => api.fetchUserProfile(id)) 462 | * .then(profileMaybe => profileMaybe.match({ 463 | * some: profile => displayProfile(profile), 464 | * none: () => showProfileNotFound() 465 | * })); 466 | * 467 | * // Chain multiple promises 468 | * maybe(user) 469 | * .flatMapPromise(user => fetchPermissions(user.id)) 470 | * .then(permissionsMaybe => permissionsMaybe.flatMap(permissions => 471 | * maybe(user).map(user => ({ ...user, permissions })) 472 | * )) 473 | * .then(userWithPermissions => renderUserDashboard(userWithPermissions)); 474 | */ 475 | flatMapPromise(fn: (val: NonNullable) => Promise): Promise>> 476 | 477 | /** 478 | * Chains Maybe operations with a function that returns an Observable. 479 | * 480 | * This allows for seamless integration with reactive streams. 481 | * The Observable result is automatically wrapped in a Maybe, with null/undefined 482 | * or empty/error emissions resulting in None. 483 | * 484 | * Note on resolution transformation: This method transforms between context types while 485 | * preserving semantic meaning: 486 | * - None values short-circuit (the Observable-returning function is never called) 487 | * - Some values are passed to the function to generate an Observable 488 | * - Only the first emission from the Observable is captured (timing loss) 489 | * - Observable emissions become Some values in the resulting Maybe 490 | * - Observable completion without emissions or errors becomes None 491 | * - Observable errors become None values 492 | * 493 | * There is timing model transformation: from continuous reactive to one-time asynchronous. 494 | * 495 | * @typeParam R - The type of the value emitted by the resulting Observable 496 | * @param fn - A function that takes the value from this Maybe and returns an Observable 497 | * @returns A Promise that resolves to a Maybe containing the first emitted value 498 | * 499 | * @requires rxjs@^7.0 500 | * @example 501 | * maybe(userId) 502 | * .flatMapObservable(id => userService.getUserSettings(id)) 503 | * .then(settingsMaybe => settingsMaybe.match({ 504 | * some: settings => applyUserSettings(settings), 505 | * none: () => applyDefaultSettings() 506 | * })); 507 | */ 508 | flatMapObservable(fn: (val: NonNullable) => import('rxjs').Observable): Promise>> 509 | 510 | /** 511 | * Maps and flattens multiple Promises in parallel, preserving the Maybe context. 512 | * 513 | * This operation allows processing an array of async operations concurrently 514 | * while maintaining the Maybe context. If the original Maybe is None, the 515 | * function is never called. Otherwise, all Promises are executed in parallel. 516 | * 517 | * @typeParam R - The type returned by each Promise in the results array 518 | * @param fn - A function that takes the value from this Maybe and returns an array of Promises 519 | * @returns A Promise that resolves to a Maybe containing an array of results 520 | * 521 | * @example 522 | * // Load multiple resources concurrently from a user ID 523 | * maybe(userId) 524 | * .flatMapMany(id => [ 525 | * api.fetchProfile(id), 526 | * api.fetchPermissions(id), 527 | * api.fetchSettings(id) 528 | * ]) 529 | * .then(resultsMaybe => resultsMaybe.match({ 530 | * some: ([profile, permissions, settings]) => displayDashboard(profile, permissions, settings), 531 | * none: () => showError('Failed to load user data') 532 | * })); 533 | */ 534 | flatMapMany(fn: (val: NonNullable) => Promise[]): Promise[]>> 535 | 536 | /** 537 | * Combines this Maybe with one or more other Maybes using a combiner function. 538 | * 539 | * If all Maybes are Some, applies the function to their values and returns 540 | * a new Some containing the result. If any is None, returns None. 541 | * 542 | * @typeParam U - The type of the value in the other Maybe 543 | * @typeParam R - The type of the combined result 544 | * @param other - Another Maybe to combine with this one 545 | * @param fn - A function that combines the values from both Maybes 546 | * @returns A new Maybe containing the combined result if all inputs are Some, otherwise None 547 | * 548 | * @example 549 | * // Combine two values 550 | * const name = maybe(user.name); 551 | * const email = maybe(user.email); 552 | * 553 | * const display = name.zipWith(email, (name, email) => `${name} <${email}>`); 554 | * // Some("John Doe ") if both exist 555 | * // None if either is missing 556 | * 557 | * @example 558 | * // Combine three values 559 | * const firstName = maybe(user.firstName); 560 | * const lastName = maybe(user.lastName); 561 | * const email = maybe(user.email); 562 | * 563 | * const contact = firstName.zipWith(lastName, email, (first, last, email) => ({ 564 | * fullName: `${first} ${last}`, 565 | * email 566 | * })); 567 | * // Some({ fullName: "John Doe", email: "john@example.com" }) if all exist 568 | * // None if any is missing 569 | * 570 | * @example 571 | * // Combine many values 572 | * const result = a.zipWith(b, c, d, e, (a, b, c, d, e) => a + b + c + d + e); 573 | */ 574 | zipWith, R>( 575 | other: IMaybe, 576 | fn: (a: NonNullable, b: U) => NonNullable 577 | ): IMaybe 578 | 579 | zipWith, V extends NonNullable, R>( 580 | m1: IMaybe, 581 | m2: IMaybe, 582 | fn: (a: NonNullable, b: U, c: V) => NonNullable 583 | ): IMaybe 584 | 585 | zipWith, V extends NonNullable, W extends NonNullable, R>( 586 | m1: IMaybe, 587 | m2: IMaybe, 588 | m3: IMaybe, 589 | fn: (a: NonNullable, b: U, c: V, d: W) => NonNullable 590 | ): IMaybe 591 | 592 | zipWith, V extends NonNullable, W extends NonNullable, X extends NonNullable, R>( 593 | m1: IMaybe, 594 | m2: IMaybe, 595 | m3: IMaybe, 596 | m4: IMaybe, 597 | fn: (a: NonNullable, b: U, c: V, d: W, e: X) => NonNullable 598 | ): IMaybe 599 | 600 | zipWith, V extends NonNullable, W extends NonNullable, X extends NonNullable, Z extends NonNullable, R>( 601 | m1: IMaybe, 602 | m2: IMaybe, 603 | m3: IMaybe, 604 | m4: IMaybe, 605 | m5: IMaybe, 606 | fn: (a: NonNullable, b: U, c: V, d: W, e: X, f: Z) => NonNullable 607 | ): IMaybe 608 | 609 | // Variadic overload for 5+ Maybes 610 | zipWith( 611 | ...args: [...IMaybe>[], (...values: NonNullable[]) => NonNullable] 612 | ): IMaybe 613 | } 614 | --------------------------------------------------------------------------------

4 | 5 | circeci 6 | 7 |