gcanti / fp-ts

Functional programming in TypeScript
https://gcanti.github.io/fp-ts/
MIT License
10.78k stars 501 forks source link

Add tap and tapLeft #1193

Closed imcotton closed 4 years ago

imcotton commented 4 years ago

🚀 Feature request

Current Behavior

import { tap } from 'ramda';

pipe(
    right(42),
    map(tap(console.log)),
    mapLeft(tap(console.error)),
)

Desired Behavior

pipe(
    right(42),
    tap(console.log),
    tapLeft(console.error),
)

Who does this impact? Who is this for?

All users.

Your environment

Software Version(s)
fp-ts 2.5.4
TypeScript 3.8.3
cyberixae commented 4 years ago

This already exists. It is called chainFirst. It is used as follows

import { pipe } from 'fp-ts/lib/pipeable'
import { right, fold } from 'fp-ts/lib/Either'
import { chainFirst } from 'fp-ts/lib/IO'
import { log, error } from 'fp-ts/lib/Console'

pipe(
    ( ) => right(42),
    chainFirst(fold(error, log)),
) ( );
steida commented 4 years ago

@cyberixae I am still confused about chainFirst. How would you explain it to beginners? Thank you!

cyberixae commented 4 years ago

@steida Read the introduction chapter of IO module. https://gcanti.github.io/fp-ts/modules/IO.ts.html Let me know if it helps. I wrote it in the hopes it would help beginners understand how this works. I can clarify further if it remains unclear but that is a good place to start reading about it.

steida commented 4 years ago

I perfectly understand the IO module. The chainFirst is a mystery.

cyberixae commented 4 years ago

It is easy to understand by comparing the type signatures of chain and chainFirst. The difference is which result you want to return. If you'd use chain for logging you'd be passing forward the undefined (or void) value B returned by the console.log call. Using chainFirst solves this by throwing away the undefined and passing forward A, the value it received as input.

export declare const chain:      <A, B>(f: (a: A) => IO<B>) => (ma: IO<A>) => IO<B>
export declare const chainFirst: <A, B>(f: (a: A) => IO<B>) => (ma: IO<A>) => IO<A>
imcotton commented 4 years ago

@cyberixae Thanks for introducing chainFirst, it's true that side effect logging should be inside of IO, however lifting the entire pipe by IO<Either> seemed adding too much weights to it, no?

cyberixae commented 4 years ago

@imcotton it depends to what degree you wish to pretend that the logging does not happen. One key advantage of fp-ts is having absolutely everything visible in the type. From that perspective lifting the type to IO is the only option. I would agree that a different set of tools would be useful for reverse-engineering and debugging runtime code. However, I would be hesitant of adding such unsafe tools to fp-ts. I think it would be better to have a separate package for such tools.

The call at the end of the pipe executes the code and breaks referential transparency. I'm hoping to avoid such calls to some degree when I'm writing functional code. I created a separate package called ruins-ts to make the operations that ruin type safety more obvious creating a clear barrier between functional and non-functional code. With ruins the chainFirst example from above would look as follows.

import { pipe } from 'fp-ts/lib/pipeable'
import { right, fold } from 'fp-ts/lib/Either'
import { chainFirst } from 'fp-ts/lib/IO'
import { log, error } from 'fp-ts/lib/Console'
import * as ruins from 'ruins-ts'

ruins.fromIO(pipe(
    ( ) => right(42),
    chainFirst(fold(error, log)),
));

Technically the package doesn't do much but using the package makes it possible to search your code base for architectural pain points. I think it would also be beneficial to have runtime debugging and reverse engineering tools in a separate package making it easier to find and eliminate or isolate such calls.

imcotton commented 4 years ago

I appreciate the explanation, indeed the type is the fundamental building blocks in FP context.

Also this ruins-ts surly looks interesting, will give it a try afterwards, thanks @cyberixae !

Lonli-Lokli commented 2 years ago
import { pipe } from 'fp-ts/lib/pipeable'
import { right, fold } from 'fp-ts/lib/Either'
import { chainFirst } from 'fp-ts/lib/IO'
import { log, error } from 'fp-ts/lib/Console'

pipe(
    ( ) => right(42),
    chainFirst(fold(error, log)),
) ( );

@cyberixae I do not think it's too generic, right? Eg in rxjs Tap works with any type.

Also in your example you have to use fold ffor both results, while usually people use either tap or tapLeft. Both R & L I would name biTap

cyberixae commented 2 years ago

@Lonli-Lokli You can use stuff from IOEither if you wish to deal with left and right separately.

import { pipe } from 'fp-ts/lib/function'
import { right, chainFirstIOK, orElseFirst, fromIO } from 'fp-ts/lib/IOEither'
import { log, error } from 'fp-ts/lib/Console'

pipe(
    right(42),
    chainFirstIOK(log),
    orElseFirst((e) => fromIO(error(e))),
)( );