fp-ts
fp-ts copied to clipboard
[RFC] `run` function
I think it would be a good idea to add a run utility function for some of the data types - like Reader, IO, Task... - and I see at least two benefits.
It would improve readability. For example, this
pipe(
TE.of(1),
TE.map(x => x + 42)
)()
would become this
pipe(
TE.of(1),
TE.map(x => x + 42),
TE.run
)
which I found a little bit more clear.
Right now they leak an implementation detail (which the user actually must know in order to run the effect); a run function would keep it hidden. Furthermore, if in the future the representation of said data structures will change, the upgrade would be painless for the users of this library.
Here a bunch of examples:
// Reader
const run = <R>(r: R) => <A>(ma: Reader<R, A>): A => ma(r)
type Env = number
pipe(
R.ask<Env>(),
R.map((x) => x % 2 === 0),
R.run(6)
) // boolean
// Task
const run = <A>(ma: Task<A>): Promise<A> => ma()
pipe(
T.of('a'),
T.map(s => s.length),
T.run
) // Promise<number>
Some question that you may have (and @gcanti already asked):
For layered data structures (like
*TaskEither) should therunfunction "remove" all the layers or only one?
In my opinion, the module should have a run function that run all the layers. If I want to run only the first layer, I can use the run function of that module. Example:
import * as R from 'fp-ts/Reader`
import * as RTE from 'fp-ts/ReaderTaskEither`
declare const m: RTE.ReaderTaskEither<number, string, boolean>
pipe(
m,
RTE.run(6)
) // Promise<Either<string, boolean>>
pipe(
m,
R.run(7)
) // TaskEither<string, boolean>
runis not a pure function for some data types (IO,Task...). For those that are layered withEither, shouldLeftbe thrown or shouldEitherbe returned?
For example, for TaskEither we can have:
const run = <E, A>(ma: TaskEither<E, A>): Promise<Either<E, A>>
const runThrowable = <E, A>(ma: TaskEither<E, A>): Promise<A>
I prefer the first one, but I guess that the module can have both of them.
I'm split.
In favour, this addition aids readability and makes it easier to spot dangling values where for example you may have refactored a -> b into a -> IO b (linting plugins may now catch this?). I actually made this exact addition in bukubrow-webext a while ago.
Against it, I've found it easier to persuade teams to adopt the likes of IO and Task by pointing out that they're just thunks, so the cost of backtracking on their usage is nothing more than a useless function call. This interface therefore also acts as an escape hatch for anyone not yet comfortable with monads et al. My concern here would be that in the short-term it obscures this fact and in the long-term it encourages moving towards another implementation. I totally appreciate that ideally you'd consider this an implementation detail that's leaking, but it may be more pragmatic to keep this as-is.
I've found it easier to persuade teams to adopt the likes of IO and Task by pointing out that they're just thunks, so the cost of backtracking on their usage is nothing more than a useless function call.
I think that you had to do this for the very reason that the abstraction is leaking implementation details and the only way to eventually run the effect is to be aware of its implementation.
As opposed to have the right tools: let's say we have Task<A>, which is the type of a value that represents an asynchronous effect, and we have function run<A>(ma: Task<A>): Promise<A> which runs the effect. Given this, why would you explain Task's implementation details?
The implementation detail right now has a helpful side effect of being very easy to walk back from if a team decides they don't want to go all-in on fp-ts, as a Task and an IO are just aliases around thunks. This makes adoption easier in a demographic that I think is important given that anyone particularly engaged with FP is going to be looking more-so at the likes of PureScript.
I like the idea of a run function but only for IO and Task. This run function can indicate that you are about to leave your pure implementation and execute your side effect which can be unsafe. But I am against a function that "runs" a Reader cause it gives the wrong idea about what the difference is between a Reader and a side effect thunk.
If I want to run only the first layer, I can use the run function of that module
this solution is "leaking an implementation detail" though
Personally I think that pipe is more about declarative composition and not about execution. I would keep pipeing till the very very end where there's a single effect execution (at the border of the app). Then it wouldn't make sense to ship the "executor" in fp-ts.
@raveclassic Why not? I still see the same benefits even without pipeing.
const ma = pipe(
T.of('a'),
T.map(s => s.length)
)
// At the boundary.
// I must know that `Task` is equivalent to a thunk returning a `Promise`.
ma()
// `run` provides a way to get a `Promise` out of a `Task`, regardless of what a `Task` actually is.
T.run(ma)
If I want to run only the first layer, I can use the run function of that module
this solution is "leaking an implementation detail" though
@gcanti Why? Let's say I have a ReaderTaskEither and I want to "run" the Reader layer, I can use run from the Reader module. What implementation detail is leaking?
@DenisFrezzato In my opinion such run helper still seems unnatural to be shipped in fp-ts core.
Let's say I have a ReaderTaskEither and I want to "run" the Reader layer, I can use run from the Reader module. What implementation detail is leaking?
I would say that it reveals the structure of ReaderTaskEither. I may name it as App and I should not know that it's "layered" and contains Reader in the first layer.
If we want a run function, it could perhaps be a single overloaded function?
~~@samhh Maybe a typeclass would fit better?~~
~~UPD: MonadReader with runReader: https://pursuit.purescript.org/packages/purescript-transformers/3.2.0/docs/Control.Monad.Reader#v:runReader~~ forget it, we can't "run" a Task<A> to get the final A
What implementation detail is leaking?
@DenisFrezzato
type ReaderTaskEither<R, E, A> = Reader<R, TaskEither<E, A>>
Repro
import { TaskEither } from 'fp-ts/TaskEither'
import { Reader } from 'fp-ts/Reader'
import { pipe } from 'fp-ts/function'
import * as RTE from 'fp-ts/ReaderTaskEither'
// let's say `ReaderTaskEither` is defined as:
export interface NewReaderTaskEither<R, E, A> {
readonly runReaderTaskEither: (r: R) => TaskEither<E, A>
}
declare const run: <R>(r: R) => <A>(ra: Reader<R, A>) => A
declare const newrte: NewReaderTaskEither<boolean, number, string>
export const x = pipe(newrte, run(true)) // error
declare const rte: RTE.ReaderTaskEither<boolean, number, string>
// const y: TaskEither<number, string>
export const y = pipe(rte, run(true))
unsafePerformIO, alongside unsafePerformTask. The implementation leaks, but it's clear that it's an unsafe operation.
I find unsafe operations are useful when integrating with other tools that aren't built using fp-ts.
With reader, I still there needs to be function like run or call.