3

A task has a few steps, if each step's input is only from direct last step, it is easy. However, more often, some steps are depend on not only the direct last step.

I can work out via several ways, but all end up with ugly nested code, I hope anyone could help me to find better ways.

I created the following signIn-like example to demonstrate, the process has 3 steps as below:

  1. get database connection (() -> Task Connection)
  2. find account (Connection -> Task Account)
  3. create token (Connection -> accountId -> Task Token)

#step3 depends not only on step#2 but also step#1.

The below are the jest unit tests by using folktale2

import {task, of} from 'folktale/concurrency/task'
import {converge} from 'ramda'

const getDbConnection = () =>
    task(({resolve}) => resolve({id: `connection${Math.floor(Math.random()* 100)}`})
)

const findOneAccount = connection =>
    task(({resolve}) => resolve({name:"ron", id: `account-${connection.id}`}))

const createToken = connection => accountId =>
    task(({resolve}) => resolve({accountId, id: `token-${connection.id}-${accountId}`}))

const liftA2 = f => (x, y) => x.map(f).ap(y)

test('attempt#1 pass the output one by one till the step needs: too many passing around', async () => {
    const result = await getDbConnection()
        .chain(conn => findOneAccount(conn).map(account => [conn, account.id])) // pass the connection to next step
        .chain(([conn, userId]) => createToken(conn)(userId))
        .map(x=>x.id)
        .run()
        .promise()

    console.log(result) // token-connection90-account-connection90
})

test('attempt#2 use ramda converge and liftA2: nested ugly', async () => {
    const result = await getDbConnection()
        .chain(converge(
            liftA2(createToken),
            [
                of,
                conn => findOneAccount(conn).map(x=>x.id)
            ]
        ))
        .chain(x=>x)
        .map(x=>x.id)
        .run()
        .promise()

    console.log(result) // token-connection59-account-connection59
})

test('attempt#3 extract shared steps: wrong',  async () => {
    const connection = getDbConnection()

    const accountId = connection
    .chain(conn => findOneAccount(conn))
    .map(result => result.id)

    const result = await of(createToken)
    .ap(connection)
    .ap(accountId)
    .chain(x=>x)
    .map(x=>x.id)
    .run()
    .promise()

    console.log(result) // token-connection53-account-connection34, wrong: get connection twice
})
  • attempt#1 is right, but I have to pass the output of very early step till the steps need it, if it is across many steps, it is very annoying.

  • attempt#2 is right too, but end up with nested code.

  • I like attempt#3, it use some variable to hold the value, but unfortunately, it doesn't work.

Update-1 I am think another way to put all outputs into a state which will pass through, but it may very similar attempt#1

test.only('attempt#4 put all outputs into a state which will pass through',  async () => {
    const result = await getDbConnection()
    .map(x=>({connection: x}))
    .map(({connection}) => ({
        connection,
        account: findOneAccount(connection)
    }))
    .chain(({account, connection})=>
        account.map(x=>x.id)
        .chain(createToken(connection))
    )
    .map(x=>x.id)
    .run()
    .promise()


    console.log(result) //     token-connection75-account-connection75
})

update-2 By using @Scott's do approach, I am pretty satisfied with the below approach. It's short and clean.

test.only('attempt#5 use do co', async () => {
    const mdo = require('fantasy-do')

    const app = mdo(function * () {
        const connection = yield getDbConnection()
        const account =  yield findOneAccount(connection)

        return createToken(connection)(account.id).map(x=>x.id)
    })

    const result = await app.run().promise()

    console.log(result)
})
Ron
  • 6,037
  • 4
  • 33
  • 52
  • 1
    I guess the question is how to access results of previous `task`s, right? Doing this with explicitly passing a state object through the chain isn't that bad. Moreover you could combine `task` with a `readerT` monad transformer (or with `stateT` if you need mutation) to abstract from that object. But I am neither sure if this abstraction is worth the effort, nor if you can implement a proper `readerT` with Javacript's prototype system. –  Sep 06 '17 at 09:36
  • @ftor, I think I need to learn some haskell to understand `readerT` monad, I guess it may like some centralized state. I will look into `readerT` and `stateT`. thanks a lot for this information. – Ron Sep 06 '17 at 10:17
  • Related, if not duplicate of [How do I access previous promise results in a .then() chain?](https://stackoverflow.com/q/28250680/1048572) - while `Task`s are not eager, their monadic interface and `parallel` combination are exactly equivalent to promises. – Bergi Sep 06 '17 at 23:45
  • @Bergi yeah, it is related. In your question, the answer for promise is `await/async` and `yield`. While in `monad` world, there is no `await/async` language level support, but we still can use `yield` with `fantacy-do` support. – Ron Sep 07 '17 at 01:08
  • @ftor, I updated the title to be more clearly, thanks. – Ron Sep 07 '17 at 01:11
  • @Ron All the other approaches are detailed in the other answers :-) – Bergi Sep 07 '17 at 01:30

1 Answers1

3

Your example could be written as follows:

const withConnection = connection =>
  findOneAccount(connection)
      .map(x => x.id)
      .chain(createToken(connection))

getDbConnection().chain(withConnection)

This is similar to your second attempt, though makes use of chain rather than ap/lift to remove the need for the subsequent chain(identity). This could also be updated to use converge if you want, though I feel it loses a great amount of readability in the process.

const withConnection = R.converge(R.chain, [
  createToken,
  R.compose(R.map(R.prop('id')), findOneAccount)
])

getDbConnection().chain(withConnection)

It could also be updated to look similar to your third attempt with the use of generators. The follow definition of the Do function could be replaced by one of the existing libraries that offers some form of "do syntax".

// sequentially calls each iteration of the generator with `chain`
const Do = genFunc => {
  const generator = genFunc()
  const cont = arg => {
    const {done, value} = generator.next(arg)
    return done ? value : value.chain(cont)
  }
  return cont()
}

Do(function*() {
  const connection = yield getDbConnection()
  const account = yield findOneAccount(connection)
  return createToken(connection)(account.id)
})
Scott Christopher
  • 6,458
  • 23
  • 26
  • thanks for your input. first code set looks clean but nested, I agreed that the second code set is not very readable. The last sounds good, it like `co` in `promise` world, interesting, though there is no such one existed. – Ron Sep 06 '17 at 22:49
  • 1
    @Ron I've edited the example to pull the nested continuation out into its own `withConnection` function, though it only superficially reduces the nesting. I've also included a simple implementation of `Do` for you, though there are some existing libraries that already offer this functionality as well like https://github.com/jwoudenberg/fantasy-do and https://github.com/pelotom/burrido – Scott Christopher Sep 06 '17 at 23:26
  • thanks for your updates. Though I still feel the updated codeset is nested, I do like your `do` approach with two libraries, it's short and clean, I already updated the passed unit test into my original post. – Ron Sep 07 '17 at 01:04