paldepind / flyd

The minimalistic but powerful, modular, functional reactive programming library in JavaScript.
MIT License
1.56k stars 84 forks source link

Do you think this Promise -> Stream conversion is a right alternative way? #194

Closed gitusp closed 5 years ago

gitusp commented 5 years ago

Hello, thank you for providing this beautiful project. Although I'm not sure whether this question should go here, I'd really like to hear opinions from this community.


As discussed #35, it seems reasonable to convert a Promise into an either-like stream. But I noticed that I usually take another approach to convert Promise. Here I describe my approach:

// My promise conversion function.
const convertPromiseIntoStream = promise => {
  const s = flyd.stream({ state: "pending" });
  promise.then(
    value => {
      s({ state: "fulfilled", value });
      s.end(true);
    },
    reason => {
      s({ state: "rejected", reason });
      s.end(true);
    }
  );
  return s;
};

// Something that handles events.
const postMessage = flyd.stream();

// Something that calls API and returns a Promise
const callPostMessageAPI = (message) => new Promise(...);

// A stream that provides the current request status.
const requestState = switchLatest(
  postMessage.map(callPostMessageAPI).map(convertPromiseIntoStream)
);

// Extracts rejected reasons from a stream.
const toReasonStream = (stream) =>
  filter(s => s.state === "rejected", stream).map(s => s.reason)

// Error handling.
flyd.on(
  alert,
  toReasonStream(requestState).map(reason => reason.message)
);

The concept behind this conversion is that "a Promise must be mapped to one of these state, 'pending' | 'fulfilled' | 'rejected'", as described here.

The first difference between this approach and flyd.fromPromise is that the stream created with convertPromiseIntoStream emits "pending state" at first. That makes it easy to know in what state a Promise is, and display some loading indicator to user. The second difference is that the stream emits the second state, "fulfilled" | "rejected", after emitting "pending", so it is easy to filter values or reasons into a new stream and handle it.

Although I find this approach very efficient, especially building a client application, and think that it should be one of the standard Promise-Stream conversions, I'm wondering if the approach is over simplifying Promise concept, because I know nobody takes the approach and I do not have a deep knowledge in FRP.

I appreciate any feedbacks from you. Thank you.

StreetStrider commented 5 years ago

Looks pretty reasonable. I think Promise<R, E> → flyd$Stream<Either<R, E>> is a straightforward conversion and your idea follows it. There was a couple of tickets here in tracker related to your topic. Looks like this is current state of the things to convert.

You've introduced pending state. The minor change is if your widget cannot return to pending state you can just initialize widget in this state and eliminate pending from stream completely. In that case you'll end up with usual Either.

StreetStrider commented 5 years ago

I've experimented with some dirty approach to this, where I pass raw Error objects in streams. Anything except Error is considered to be a data. It's just another way to implement Either. The main idea is you're not obliged to wrap all your data. You just pass data as usual, but in some cases Error can occur (in that case you still need combinators to extract data or error just like with Either).

gitusp commented 5 years ago

@StreetStrider Thank you for your feedback. Now I understand there's cases that the approach cannot apply as you mentioned. I think whether the approach can apply depends on what I focus, the Promise's result or state.

I like the idea to pass raw Error objects in streams. There must be varied ways to handle error with stream and each has slightly different meaning. Although passing raw Error is almost the same with Either, it seems that it treats data and Error as rather close level things than Either - Either explicitly expresses which is "Right" while this approach does not.

I close this issue since my question is resolved. Thank you.

StreetStrider commented 5 years ago

@gitusp I've recalled related discussions #171 #35 #164 you can take a look at them. If any traction someday will occur, I expect it to occur in that tickets.

nordfjord commented 5 years ago

I'm of the opinion that this is something that should be solved in user land.

The fact is we all have different use cases for converting promises, sometimes we need a loader, sometimes we want to maintain order, sometimes we just want all promises to push to a result stream.

In my codebase I've used:

const promiseToEitherStream = p => {
  const s = stream();
  p.then(val => s(Right(val))).catch(err => s(Left(err)))
  return s
}

const promiseToResultStream = p => {
  const s = stream(Left('pending'))
  p.then(value => s(Right(value))).catch(error => s(Left(error)))
  return s
}

// usage
const result = stream(url)
  .map(makeRequest)
  .chain(promiseToResultStream)
  .map(cata({
    Left: R.ifElse(R.equals('pending'), renderLoader, renderError),
    Right: renderView
  }))

function cata(cata) {
  return monad => monad.cata(cata);
}

function renderLoader() {
  return <Loader />
}

function renderError(err) {
  return <Error error={err} />
}

function renderView(data) {
  // ...
}

And in some cases depending on whether ordering must be preserved I use either flyd.fromPromise or flyd.flattenPromise.

I don't think flyd should prescribe to it's users how to interact with promises.

gitusp commented 5 years ago

I agree with you. promiseToEitherStream and promiseToResultStream are both necessary depending on use cases. There should be several ways to convert a promise to a stream. As you showed, emitting pending as Left seems more semantic for me, since pending is meta information and the domain object is the promise's result.