mobily / ts-belt

🔧 Fast, modern, and practical utility library for FP in TypeScript.
https://mobily.github.io/ts-belt
MIT License
1.12k stars 31 forks source link

✨ Introducing `ts-belt` v4 (release candidate) #51

Open mobily opened 2 years ago

mobily commented 2 years ago

hello there! 👋

I have been working on the new version of ts-belt with support for Async* modules for a quite long time, and now I feel it's a great time to publish (at least) a release candidate version. It's also an excellent opportunity to gather feedback from you :) The bad news is, the docs for these modules are missing at the moment (I'm working on it!), but let me describe the essentials of each module:

Installation

yarn add @mobily/ts-belt@next

AsyncData

AsyncData contains a variant type for representing the different states in which a value can be during an asynchronous data load.

There are four possible states: Init, Loading, Reloading, and Complete.

type AsyncData<T> = Init | Loading | Reloading<T> | Complete<T>
import { AD } from '@mobily/ts-belt'

Variant constructors:

Functions:

Example: https://codesandbox.io/s/cool-star-6m87kk?file=/src/App.tsx

AsyncDataResult

AsyncDataResult is basically an alias of AsyncData<Result<Ok, Error>>. This variant type can be used to represent the different states in which a data value can exist while being loaded asynchronously, with the possibility of either success or failure.

type AsyncDataResult<A, B> = AsyncData<Result<A, B>>
import { ADR } from '@mobily/ts-belt'

Variant constructors:

Functions:

Example: https://codesandbox.io/s/brave-cloud-ov30h7?file=/src/App.tsx

AsyncOption

Same as Option but for handling asynchronous operations.

type AsyncOption<T> = Promise<Option<T>>
import { AO } from '@mobily/ts-belt'

Variant constructors:

Functions:

AsyncResult

Same as Result but for handling asynchronous operations.

type AsyncResult<A, B> = Promise<Result<A, B>>
import { AR } from '@mobily/ts-belt'

Variant constructors:

Functions:

Minor changes

Breaking changes

ts-belt@v4 does not support Flow, due to a lack of proper features in flowgen, sorry about that!

Feel free to post your thoughts, any kind of feedback would be greatly appreciated! 💪

alexn-s commented 2 years ago

looks really good, i was just getting back to ts-belt and have a lot of async code. 💖

@mobily what is the equivalent of fp-ts eitherAsync tryCatch? or the belt/Result/fromExecution in AsyncResult

is it AR.handleError?

ivklgn commented 2 years ago

How about alternative match/with from ts-pattern out of box? something like fold but for handling all or each states?

Nodonisko commented 2 years ago

Looks good, but I am starting to get lost in all that shortcuts AD, ADR, AO... Did you thinked about shipping also "non-shortcut" version of these?

mobily commented 2 years ago

what is the equivalent of fp-ts eitherAsync tryCatch? or the belt/Result/fromExecution in AsyncResult

@alexn-s the constructor of the AsyncResult variant is named: AR.make, you use it like the following:

const result = await pipe(
  AR.make(promiseFn()),
  AR.map(value => …),
  AR.getWithDefault(…),
)

something like fold but for handling all or each states?

@lulldev that sounds like a great idea, I will add it to AsyncData and AsyncDataResult for sure!

Looks good, but I am starting to get lost in all that shortcuts AD, ADR, AO... Did you thinked about shipping also "non-shortcut" version of these?

@Nodonisko yes, I hear you, and totally agree, I was against adding full namespace names due to the conflict with native namespaces, however, I feel like this might be a good alternative for namespace abbreviations (by the way I was about to merge this PR https://github.com/mobily/ts-belt/pull/35 however it's been closed recently, sorry @cevr!)

ivklgn commented 2 years ago

@mobily can i help with release candidate and contribute?

mobily commented 2 years ago

@lulldev sure thing, that would be awesome! do you need anything from me to get started?

ivklgn commented 2 years ago

@mobily yes. i want to know more about flow for contributors. What exactly can I help to do and where is the list of what is left for the release candidate?

ivan-kleshnin commented 1 year ago

@mobily I've just started to use ts-belt. Everything looks good but why there's no A.flatMap? I've checked all other popular names for such functions like chain, concatMap, etc... but it seems to be really missing. Interestingly enough, O.flatMap and R.flatMap do exist 🤔 Should I raise an issue for it?

mobily commented 1 year ago

@ivan-kleshnin added in v4.0.0-rc.5 🚀 https://github.com/mobily/ts-belt/commit/14a5c153b4d554cd304b0c6e44b671fc1d7d1d81

benchmarks:

flatMap (single function call)

✔  @mobily/ts-belt  27,383,074.99  ops/sec  ±0.33%  (99 runs)  fastest
██████████████████████████████████████████████████████████████████████

✔  remeda            1,759,670.66  ops/sec  ±1.36%  (97 runs)  -93.57%
████

✔  ramda             1,392,700.93  ops/sec  ±0.52%  (91 runs)  -94.91%
███

✔  rambda            4,870,498.47  ops/sec  ±0.92%  (98 runs)  -82.21%
████████████

✔  lodash/fp         5,749,906.26  ops/sec  ±0.78%  (87 runs)  -79.00%
██████████████

→ Fastest is @mobily/ts-belt

flatMap (function call inside pipe)

✔  @mobily/ts-belt  21,116,789.82  ops/sec  ±2.48%  (94 runs)  fastest
██████████████████████████████████████████████████████████████████████

✔  remeda            2,500,686.11  ops/sec  ±1.44%  (98 runs)  -88.16%
████████

✔  ramda               872,490.77  ops/sec  ±0.77%  (92 runs)  -95.87%
██

✔  rambda            4,248,478.35  ops/sec  ±0.54%  (93 runs)  -79.88%
██████████████

✔  lodash/fp           896,410.20  ops/sec  ±1.63%  (93 runs)  -95.75%
██

→ Fastest is @mobily/ts-belt

mobily commented 1 year ago

other new additions or bug fixes in v4:

work on the new documentation website is going pretty slowly since I do not have much spare time at this particular moment, but I will keep you posted on the progress!

kirillrogovoy commented 1 year ago

One pattern I find myself using

pipe(
   // ... some calculation resulting in an AsyncResult,
  AR.flatMap((...) => AR.make(promiseFn1())),
  AR.flatMap((...) => AR.make(promiseFn2())),
  AR.flatMap((...) => AR.make(promiseFn3())),
)

In other words, I have a bunch of functions that simply return a Promise and know nothing about ts-belt, and I want to use them as is. But I constantly have to covert them into AsyncResults which has become on ergonomics issue.

I wish flatMap() callback function could just return either AsyncResult or just a Promise and it would get normalized to AsyncResult under the hood.

Or am I missing a simpler way of doing it?

kirillrogovoy commented 1 year ago

Also, the line between R.fromPromise() and AR.make() seems kind of blurry. I'm utterly confused which one to use as they both seem to do the same thing (?).

mobily commented 1 year ago

I wish flatMap() callback function could just return either AsyncResult or just a Promise and it would get normalized to AsyncResult under the hood.

@kirillrogovoy it totally makes sense, I will update both, AR.flatMap and AO.flatMap

R.fromPromise() and AR.make()

you can actually use both alternately to achieve the same thing

kirillrogovoy commented 1 year ago

Thanks!

Actually, I've been using ts-belt this whole time since I saw this release candidate. Thanks for making it!

I made a thing I thought you'd be curious about: I've created a single map function that, depending on the input, pretends to be one of: R.map, R.flatMap, AR.map, AR.flatMap, AR.fold, and it takes care of Promise -> AR transformation.

The best part – it doesn't lose the TS typing. So code like this is possible:

pipe(
  123,
  (v) => R.Ok(v), // now it's a Result
  map(v => R.Ok(v)), // works like R.flatMap
  map(v => fetch(...)), // now it's an AR
  map(r => x ? R.Ok(x) : R.Error('OH_OH' as const)), // works like AR.fold
  map(r => functionThatReturnsAR(r)) // works like AR.flatMap
) // AR.AsyncResult<Foo, Error | 'OH_OH'>

It completely replaced all the functions above in my code with zero downsides except for a few tiny typing nuances I'll fix.

It doesn't meet the bar for a contribution (yet), and I don't know if it should belong to this repo, but let me know if you want a quick demo and/or to chat about it.

cevr commented 1 year ago

@kirillrogovoy i think this would make sense for all utilities that are shared (map, flatmap, fold)

ivan-kleshnin commented 1 year ago

@kirillrogovoy i think this would make sense for all utilities that are shared (map, flatmap, fold)

Such ultra generic functions fail shortly with type inference, at least in my experience. I've personally started to use ts-belt because its functions are split per module and inference works 100% of the time. Unlike Ramda, Rambda and other libraries which have a single map, flatMap, etc. instances, just as proposed above. Given how many library authors, very competent guys, failed to achieve the same goal I doubt it's possible.

When you replace a seed literal 123 here: pipe(123, map(...), filter(...)) with a variable, let alone generically typed, you'll very soon have to explicitly type function arguments. To resolve ambiguity which arises from over-generic code. In the best case it will be more code to write. In the worst case it won't type at all. TypeScript can't magically guess that you meant "that map overload for arrays, not that for promises".

kirillrogovoy commented 1 year ago

@ivan-kleshnin my experience had been roughly the same and I can relate to every frustration; up until I did manage to cobble something together for myself.

I've just literally copy-pasted it from my repo to here so you can try it for yourself.

At the time of writing this, I have 42 calls of mmap in my code in a lot of different contexts, and there's no place where the typings get screwed up.

All that said, I still consider it an intellectual experiment of "how far I can push this idea." By no means am I making a case that this is a superior way of doing anything. Treat it more as research rather than an end-product.

Having taken a look at various map functions in Rambda and others, it feels like whatever generic API they are doing is related to working with collections (arrays, objects, Maps, etc.) and not containers (Promise, Result, etc.) I wasn't trying to solve any problem that they were trying to solve, so maybe I was unaffected by the challenges of that domain.

ivan-kleshnin commented 1 year ago

@kirillrogovoy thank you for the information! It's great that you have worked through forementioned challenges.

Another concern I have is that to extend map, filter, reduce, etc to support even a single new type I would have to wrap and reexport each function. It's an expression problem in its purest form. With native approach of Ts-Belt I would simply make a new module (to support e.g. Set helpers). The latter sounds much better: easier to do and to support (not counting cross-type functions which are still an issue).

TypeScript does not have typeclasses (yet?) so categorical applications of such generic functions are out of question. So, aside of being a nice proof of infer power in TypeScript, what benefit this new API adds, from your point of view?

kirillrogovoy commented 1 year ago

I would have to wrap and reexport each function

I guess it's true in theory, but in practice, I don't actually pattern-match and choose the right ts-belt function in my code. Instead, I wrote an implementation from scratch (60 LOC) that accounts for all the different types. At their base, mapping functions are extremely simple. I spent maybe 5% of the time writing the implementation, and 95% dealing with the types. 😅

TypeScript does not have typeclasses (yet?) so categorical applications of such generic functions are out of question.

Unfortunately, I understand next to no FP theory — certainly not enough to understand what typeclasses and categorical applications are. I've just tried to read some Haskell examples but didn't really grasp much.

what benefit this new API adds, from your point of view?

For me, it's simply ergonomics. If I can stop thinking about which of the 6 functions to use with virtually no downside, it's a win already. At least, in this specific case where only one of six functions is actually valid and applicable depending on the input and what my mapping function returns.

One specific example was that I'd always forget which of AR.fold and AR.flatMap expect me to return a Result vs AsyncResult. Again, given that there's only one function that's even valid for my case, that's the kind of decision I just don't want to think about.

Another side of that example is being annoyed every time I need to change AR.map to AR.flatMap / AR.fold because I'm introducing some IO and/or Result in the mapping function. I always think "hey, Typescript knows what I'm trying to do, why can't it just select the right overload for me?!"

I also understand that this problem may be just a matter of preference. I have nothing against someone else wanting to write all those functions explicitly for any reason. But in my personal experience, it doesn't add value either to the writing experience or to the readability of the code. I prefer to be lazy and have a magic function haha.

ivan-kleshnin commented 1 year ago

@kirillrogovoy thank you for the explanation, your points are clear. My experience differs but I don't mind about an extra code layout option to choose from. Keep up 👍

M3kH commented 1 year ago

I've being playing a bit with ts-belt and is nice to see the async utilities to pop-up in the next version. Thanks for make this library.

One thing I'm missing, maybe also related to the above, is getting a proper flow with Promises and Array or Dictionary to unfold.

A small example:

pipe(
  fetch(someData),
  A.filterMap(async (response) => {
     const { body } = await response;
     return !!body ? ADR.makeCompleteOk(response.body) : ADR.makeCompleteError("No body found");
  })
)

Maybe this should be possible with Async types, but it's unclear to me; sorry for my ignorance. I will believe that the underline Array and Dictionary might need to adapt in accepting Promises.

afoures commented 1 year ago

@kirillrogovoy I think I found a bug in your implementation of mmap

const result: Result<{id: string}, 'failed'> = R.Error('failed') // some result containing an error
const x = pipe(
  result,
  mmap(({ id }) => fetch('/user/' + id)), // should convert result to async result because fetch returns a promise
); // AR.AsyncResult<User, FetchError | 'failed'>
// but here x is not an async result because mmap skipped executing the async callback, so typescript and reality are not in sync

I think that the same function can not handle sync AND async at the same time when working with Results

kirillrogovoy commented 1 year ago

Hey Antoine,

Yeah, you're right. This is one of the limitations that I couldn't fix.

Essentially, Typescript knows the function signature and so it kind of knows that the function would return a promise, but Javascript doesn't know that without actually running the function.

The right solution would probably be restricting it in TS land and forcing you to explicitly convert result to a promise (e.g. AR.make(result) first.

That said, there's currently no good solution in ts-belt either. If you use R.map, you end up with Result<Promise<User>, FetchError | 'failed'>

denizdogan commented 1 year ago

R.fromPromise() and AR.make()

you can actually use both alternately to achieve the same thing

Is this still accurate? They seem similar on the surface, but looking at the types there's differences, no?

AR.make<A, B = globalThis.Error>(promise: Promise<A>): AsyncResult<A, B>;

R.fromPromise<A>(promise: Promise<A>): Promise<Result<ExtractValue<A>, globalThis.Error>>;

So AR.make seems more flexible than R.fromPromise in that it:

Is the difference here intentional?

denizdogan commented 1 year ago

By the way, why is there a NonNullable constraint on R.fromPromise anyway?

mattaiod commented 1 year ago

Hello @mobily

When do you expect to publish the official version?

Thank you for your incredible work 👍

IAmNatch commented 1 year ago

Pardon me if this has been covered already, but is there any way to essentially have a sequential async pipe with this new system? Ideally we could do async operations in the pipe, and have the promise resolve before moving on to the next step? -- I'm mainly thinking about db and API calls, that pass data into the next call and so forth.

ie something like:

const pipeline = asyncPipe(
    createValueAsync,
    D.updateAsync, 
    D.updateAsync,
);

const result = await pipeline;
vdawg-git commented 1 year ago

87 Please also consider this for v4 :)

gustavopch commented 1 year ago

I see that the last commit was on January. I want to use ts-belt in my project, but I'm a bit worried about its future. @mobily Just to have some idea of what to expect: do you plan to keep maintaining ts-belt or did you maybe stop using it yourself in your own projects or something else? By the way, thanks for your work until here, ts-belt looks awesome.

kirillrogovoy commented 1 year ago

While it seems that Marcin is busy with another project, I wanted to mention that I've been using ts-belt@4.0.0 in production for almost a year and it's been going pretty well. I only wish we could merge it into main one day.

stefvw93 commented 1 year ago

Pardon me if this has been covered already, but is there any way to essentially have a sequential async pipe with this new system? Ideally we could do async operations in the pipe, and have the promise resolve before moving on to the next step? -- I'm mainly thinking about db and API calls, that pass data into the next call and so forth.

ie something like:

const pipeline = asyncPipe(
    createValueAsync,
    D.updateAsync, 
    D.updateAsync,
);

const result = await pipeline;

@IAmNatch I added this in my own code base, but would be nice if ts-belt has it!

/**
 * Performs left-to-right async composition (the first argument must be a value).
 */
export function pipeAsync<A, B>(value: A, fn1: Task<A, B>): Promise<B>;
export function pipeAsync<A, B, C>(value: A, fn1: Task<A, B>, fn2: Task<B, C>): Promise<C>;

// ... add more function overloads as you require here...

export async function pipeAsync<A, B>(value: A, ...fns: Task<unknown, unknown>[]): Promise<B> {
    return fns.reduce<unknown>(async (acc, fn) => await fn(await acc), value) as B;
}

type Task<A, B> = (arg: A) => Promise<B> | B;

Or flowAsync

/**
 * Performs left-to-right promise composition and returns a new function, the first argument may have any arity, the remaining arguments must be unary.
 */
export function flowAsync<A extends Args, B>(fn1: LeadingTask<A, B>): (...args: A) => Promise<B>;
export function flowAsync<A extends Args, B, C>(fn1: LeadingTask<A, B>, fn2: TrailingTask<B, C>): (...args: A) => Promise<C>;

// ... add more function overloads as you require here...

export function flowAsync<A extends Args, B>(
    fn1: LeadingTask<A, unknown>,
    ...fns: TrailingTask<unknown, unknown>[]
): (...args: A) => Promise<B> {
    return (...args: A) =>
        fns.reduce<unknown>(async (acc, fn) => await fn(acc), fn1(...args)) as Promise<B>;
}

type Args = ReadonlyArray<unknown>;
type LeadingTask<A extends Args, B> = (...args: A) => Promise<B> | B;
type TrailingTask<A, B> = (arg: A) => Promise<B> | B;

Examples:

const notificationSettingsByUserId = await pipeAsync(
    "0a0ea077-22e7-4735-af13-e2ec0279c7f1",
    getUserById,
    getNotificationSettingsOfUser
)
const getNotificationSettingsByUserId = flowAsync(
    getUserById,
    getNotificationSettingsOfUser
)

await getNotificationSettingsByUserId("0a0ea077-22e7-4735-af13-e2ec0279c7f1")
stefvw93 commented 1 year ago

Another feature that I think would be useful as a Function scope utility (or new Thunk scope? ), is a way to apply unary thunks in point free notation. A particular use case where I find this useful, is when isolating side effects in (function) composition patterns. It works similar to F.identity but for unary functions or thunks, instead of values.

Simple example thunk for isolating reading from local storage:

const readFromStorage = (key) => () => { ... logic }
const readUserFromStorage = readFromStorage("some-user-id");
const user = readUserFromStorage();

When applying this pattern in composition, it is awkward (or impossible?) to write point free:

const result = pipe(
  "some-user-id",
  (id) => readFromStorage(id)()
)

At the moment I am using my own utility, which I simply call apply. It looks like this (taken from some production code base but edited to fit here):

// isolated local storage read op side effect
const readFromStorage = <T = unknown>(key: string) => () => pipe(
  R.fromExecution(() => localStorage.getItem(key)),
  R.tapError(console.error),
  R.flatMap(R.fromNullable("data is null")),
  R.flatMap(parseJson<T>),
);

const parseJson = <T = unknown>(value: string) => pipe(
  R.fromExecution(() => JSON.parse(value)),
  R.tapError(console.error),
  R.map(F.coerce<T>),
)

const getUserStorageId = (userId: string) => `user:${userId}`;
const getProgressStorageId = (progressId: string) => `progress:${progressId}`;

/**
 * Find user progress by user ID in a normalised JSON storage
 */
const getProgressByUserId = flow(
  getUserStorageId,
  apply(readFromStorage<User>), // apply functor with user store id in composition
  R.map(D.get('progressId')),
  R.map(getProgressStorageId),
  R.flatMap(apply(readFromStorage<Progress>)), // apply functor with progress storage id in composition
  R.toNullable
)
const progress = getProgressByUserId("73d6aa07")

TypeScript implementation of my apply utility:

export function apply<A, B>(arg: A, fn: (arg: A) => B): ReturnType<typeof fn>;
export function apply<A, B extends (...args: readonly unknown[]) => unknown>(
    fn: (arg: A) => B,
): (arg: A) => ReturnType<ReturnType<typeof fn>>;

export function apply<A, B>(
    argOrFn: A | ((arg: A) => (...args: readonly unknown[]) => B),
    fn?: (arg: A) => B,
) {
    return argOrFn instanceof Function ? (arg: A) => argOrFn(arg)() : fn!(argOrFn);
}
mobily commented 11 months ago

hello everyone! 👋 please accept my apologies for the inactivity, you can read more here: https://github.com/mobily/ts-belt/issues/93

JUSTIVE commented 9 months ago

found Option.fold was missing from the release note above, and the implementation should be fixed. current implementation is same as O.match. It should be behave as same as O.getWithDefault.