seangwright / typescript-functional-extensions

A TypeScript implementation of the C# library CSharpFunctionalExtensions, including synchronous and asynchronous Maybe and Result monads.
MIT License
33 stars 4 forks source link

Feature: Pipeable functions #4

Closed seangwright closed 2 years ago

seangwright commented 2 years ago

Is your feature request related to a problem? Please describe. Currently the monads in this library are monolithic since they are defined as classes with methods.

The library size isn't huge when minified, but with tree shaking in modern bundlers (and no-bundle ES Module browser scenarios), it would be nice to only pay for the operators used.

It would also be nice for library consumers to be able to enhance the monads with new operators without needing to use inheritance or update the library. Example: Domain/app-specific custom operators are currently not easy to add.

Describe the solution you'd like To use the monad operations we create an instance and then use the instance methods:

Maybe.some(1)
  .map(n => n + 1)
  .map(n => n * 2)
  .map(n => `Number: ${n}`)
  .execute(n => console.log(n));

Having each operator as a separate function, which can be imported, and composed with other operators using a pipe() function is an option.

Libraries like RxJs use this approach since there is no language native 'pipe' operator.

pipe() would be an instance method on the monad:

pipe<TReturn>(
  projection: FunctionOfTtoK<Maybe<TValue>, TReturn>
): TReturn {
  return projection(this);
}

Most of the 'state' querying methods/getters would remain in the class (eg getValueOrThrow()/hasValue), though the static construction methods could potentially be broken out into separate functions.

Then each operator would be its own function:

function map<TValue, TNewValue>(
  projection: FunctionOfTtoK<TValue, Some<TNewValue>>
): FunctionOfTtoK<Maybe<TValue>, Maybe<TNewValue>> {
  return (maybe) => {
    return maybe.hasValue
      ? Maybe.some(projection(maybe.getValueOrThrow()))
      : Maybe.none<TNewValue>();
  };
}

function execute<TValue>(
  action: ActionOfT<TValue>
): ActionOfT<Maybe<TValue>> {
  return (maybe) => {
    if (maybe.hasValue) {
      action(maybe.getValueOrThrow());
    }
  };
}

These operator functions could be used by successive calls to .pipe():

Maybe.some(1)
  .pipe(map((n) => n + 1))
  .pipe(map((n) => n - 1))
  .pipe(map((n) => `${n}`))
  .pipe(execute(n => console.log(n)));

A domain-specific operator could be added by just creating a new function:

import { log } from './myLogger';

function tapLog<TValue>(logLevel: 'debug' | 'error' = 'debug'): FunctionOfTtoK<Maybe<TValue>, Maybe<TValue>> {
  return (maybe) => {
      if (maybe.hasValue) {
          log[logLevel](`Value: ${JSON.stringify(maybe.getValueOrThrow())}`);
      }

      return maybe;
  }
}

Maybe.some(1)
  .pipe(map(n => n + 1))
  .pipe(tapLog('debug'));

Describe alternatives you've considered The repeated calls to .pipe() could be avoided by adopting the complex overloads and type definitions that RxJs uses for its Observable.pipe() method, but that would add a lot of complexity to the library.

Additional context I'm not sure whether it would make sense to support both the class method approach and the operator function approach in the same library - though the class methods could use the operator functions, which would reduce logic duplication.

There would probably be a need for separate monad types, like Maybe and MaybeFn (but with a better name!)

It's worth noting that having the class instance methods makes the monad operations a lot more discoverable. A dev can . their way to success. By making everything a separate function, one would have to consult the docs or know what to import.

It wouldn't be nearly as easy to quickly compare available operations either.

seangwright commented 2 years ago

Prototype solution

I have an initial implementation of .pipe() that accepts up to 8 functions as parameters (which is based on the RxJs Observable.pipe() code linked above):

test('executes all operator functions', () => {
    const sut = Maybe.some(1);

    const maybe = sut.pipe(
      map((n) => n + 1),
      map((n) => n * 2),
      map((n) => `Calculation: ${n}`)
    );

    expect(sut).toHaveValue(1);
    expect(maybe).toHaveValue('Calculation: 4');
});

Problems

I've noticed 2 issues:

  1. Switching from sync (eg Maybe) to async (eg MaybeAsync) isn't very ergonomic and requires many extra .pipe() overloads.
  2. Even with those overloads, importing both sync and async operator functions into the same module causes annoying naming collisions (ex: you want to use Maybe.map and MaybeAsync.map as pipeable functions in the same file).

In regards to 1, we want to enable it, but we can consider it a dead-end for the given .pipe() call and then require developers to make another .pipe() call, which would be the MaybeAsync.pipe():

Maybe.some(1)
  .pipe(map(n => n + 1), mapAsync(n => Promise.resolve(n * 2)) // this a call to Maybe.pipe()
  .pipe(or(5), tap(n => console.log(n)); // this is a call to MaybeAsync.pipe()

In regards to 2, it's the fundamental nature of this library to separate the async/sync cases for the monads so that developers don't have to pay the 'unwrap' cost with Promises when working with purely synchronous values.

This means that while .pipe() could be a useful feature for custom operator functions, it's not ever going to be as nice of composing operations as the class methods, which inherently namespace themselves (by making the namespacing transparent).

Conclusion

This library has some existing goals:

  1. Easy to adopt into a project - no sub-dependencies, infrastructure setup
  2. Easy to learn - class methods and fluent chaining syntax
  3. Easy to use with or without Promises - sync types (Maybe, Result) can be converted to async ones (MaybeAsync, ResultAsync) but don't require them.

While it would be nice to have a purely composable solution for the benefit of package size with tree shaking, it's not compatible with some of the above goals.

However, adding .pipe() is probably worthwhile if only to allow developers to integrate their own custom operator functions in a natural way, which improves the flexibility of the library while maintaining its ease-of-use.