folktale / data.task

Migrating to https://github.com/origamitower/folktale
MIT License
425 stars 45 forks source link

How could Task#concat concat resolved values and pass them to Task#fork? #34

Closed bradparker closed 8 years ago

bradparker commented 8 years ago

N.B. I'm not at all familiar with this style of programming. But I'm trying to learn :)

The logic might be similar to Task#ap but rather than applying a function, it calls some concatenating method with both resolved values (provided they both resolve).

You could even say that values in concat-able Tasks have to themselves be members of Semigroup?

// concat :: (Semigroup a, Semigroup b) => Task a b -> Task a b -> Task a b

So any value takes care of its own implementation of concat:

function MyTaskResult (value) { this.value = value }
// concat :: MyTaskResult Object -> MyTaskResult Object -> MyTaskResult Object
MyTaskResult.prototype.concat = function (b) {
  return new MyTaskResult(assign({}, this.value, b.value))
}

... being that will only work out-of-the-box for Arrays and Strings you could alternatively provide the function used for the concatenation in the call to Task#concat?

oneTask.concat(twoTask, (a, b) => assign({}, a, b))
threeTask.concat(fourTask, (a, b) => a.concat(b))

I'm not thrilled with the above... it'd be pretty easy to wrap network responses in arrays when needed, and alternatively leave completed file-reads as Strings.

const toArray = (res) => [res]
const reqOne = new Task(
  (rej, res) => fetch("foo").then(json).then(res).catch(rej)
).map(toArray)
const reqTwo = new Task(
  (rej, res) => fetch("bar").then(json).then(res).catch(rej)
).map(toArray)
reqOne.concat(reqTwo).fork((err) => { /* boo */ }, (combinedResult) => { /* yay */ })

const fileOne = new Task(
  (rej, res) => fs.readFile('./test1.txt', (err, data) => { err ? rej(err) : res(data) }
)
const fileTwo = new Task(
  (rej, res) => fs.readFile('./test2.txt', (err, data) => { err ? rej(err) : res(data) }
)
fileOne.concat(fileTwo).fork((err) => { /* boo */ }, (combinedResult) => { /* yay */ })

I see that my specific use-case is covered by core.async but I noticed concat in the source and thought I'd ask.

Thanks for the cool and thought-provoking lib :)

rjmk commented 8 years ago

Hi @bradparker. I'm not sure that the purpose of Task#concatis to alias Task.of(concat).ap(Task.of(...)).ap(Task.of(...)) (where concat is a method from something like Ramda that dispatches to concat on objects).

As I understand your question, that's the way you're hoping to use it. Please correct me if that's wrong!

The purpose of Task#concat (I think) is to provide the value of the first Task to resolve

robotlolita commented 8 years ago

Hm, so, there are many ways of implementing a .concat method for Task. For the one in the library we've chosen non-deterministic concurrency as the "most likely use case for this". This will probably change in the redesign of the library (https://github.com/origamitower/folktale/issues/5), and .concat will probably do deterministic concurrency instead (which is similar to what you propose), renaming the current function to race or something.

This basically means that Task#concat is implemented today such that:

delay(10).concat(delay(1000))  // ==> delay(10)
delay(1000).concat(delay(10))  // ==> delay(10)

So it just selects the task that finishes first, and propagates that value. This is pretty much the same behaviour as Promise.race in the standard JS library.

Could we have implemented Task#concat differently? Sure. We could have instead treated the contents of the tasks as semigroups. This would require people to make sure the value the task resolves to has a .concat method:

Task.of([1]).concat(Task.of([2])) // ==> Task.of([1, 2])
Task.of(1).concat(Task.of([2]))  // ==> "No method `.concat` for 1"

This is also a reasonable approach. I think .ap addresses that use case right now, and also allows you to decide what "concatenation" means, so you can handle values that are not necessarily semi-groups, although it takes a bit more of effort:

const t1 = Task.of(1);
const t2 = Task.of(2);

Task.of(a => b => [a, b]).ap(t1).ap(t2)  // ==> Task.of([1, 2])

The problem with deciding between one or the other is that JavaScript only really allows you to have one implementation (you need wrappers for anything else), and for this library we thought having a behaviour like Promise.race would be more useful (you can see the discussion we had here: https://github.com/folktale/data.task/pull/15). Also because writing a Monoid instance requires either return-type polymorphism (to select the proper type to fill the Task with), or converting Task to a free monoid (which is pretty cool, but also pretty complicated: http://lambda-the-ultimate.org/node/5109).

core.async does indeed provide a few more convenient utility methods over these. With the redesign I want to make that more convenient to use, and more reliable. But that's still going to take a couple of months.

bradparker commented 8 years ago

So glad I asked!

Task#ap is exactly it, I just didn't see it, thanks for the explanation. It allows for more interesting methods for combining values, without having to write a wrapper with a specific implementation of concat. You could provide a function which merges arrays of resources based on an identifier, adds fields calculated from one result to the records in another... Perfect.

Thanks again :)