gcanti / fp-ts

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

Start working on v2 #823

Closed gcanti closed 5 years ago

gcanti commented 5 years ago

@sledorze @raveclassic @grossbart

I was waiting for https://github.com/Microsoft/TypeScript/pull/26349 in order to start working on fp-ts@2.x but alas looks like it will be delayed for an indefinite period.

The current codebase has accumulated some cruft during the last few months, which is a good thing because fp-ts is now way better than say a year ago, but is also much confusing for new comers.

I think it's time to clean up the codebase and release a polished fp-ts@2.x version:

What do you think?

sledorze commented 5 years ago

@gcanti That's a huge difference with latest! I wonder what's the diff between going through module import

option.map(option.chain(option.map(some(1), n => n * 2), n => (n > 2 ? some(n) : none)), n => n + 1)

and direct import (saving the module access)

import { map, chain } from '...'

map(chain(map(some(1), n => n * 2), n => (n > 2 ? some(n) : none)), n => n + 1)

?

gcanti commented 5 years ago

@sledorze not a notable diff (btw here's the source code)

fluent x 54,585,540 ops/sec ±0.42% (89 runs sampled)
static dictionary x 58,900,842 ops/sec ±0.39% (85 runs sampled)
static dictionary (direct) x 63,431,598 ops/sec ±1.04% (85 runs sampled)

Note how the numbers are different this morning... benchmarks aren't my thing, clearly.

Anyway, 58 (or even 48) millions of operations is still a large number and keep in mind that this is a synchronous computation, with TaskEither or ReaderTaskEither (i.e. the presumably most common monad stacks) a difference from v1 looks even less important.

TLDR: IMO we should really focus on correctness and DX rather than performance.

gcanti commented 5 years ago

focus on correctness and DX

in this order

sledorze commented 5 years ago

I really think that, with the new proposed version, I would generate proxy files in order to help with imports (and also perf) without compromising Correctness. (those would define mapArray or mapOption for instance) I m OK with that solution, maybe it can be part of an external repo if too controversial.

leemhenson commented 5 years ago

I definite +1 in favour of the classless approach is that it appears to have resolved the out-of-memory issue I have been having: https://github.com/Microsoft/TypeScript/issues/30429

Typescript now doesn't have to do structural equality over the large surface areas of classes like None, Some, Left, Right etc, and all the types they themselves reference.

Got an ETA for the release?

gcanti commented 5 years ago

@leemhenson that's good news! thanks for the feedback.

Got an ETA for the release?

Not yet. It also depends on the community feedback, I could release an alpha version if it helps.

leemhenson commented 5 years ago

I'm using the v2-lib branch which is sufficient at the moment.

gcanti commented 5 years ago

@leemhenson this is the list of test branches

Example (installing from GitHub): npm i gcanti/io-ts#fp-ts-v2

leemhenson commented 5 years ago

Awesome, thanks.

gcanti commented 5 years ago

For what concerns the ecosystem, I also checked

and upgrading looks straightforward

YBogomolov commented 5 years ago

@gcanti Thank you for pinging me! I've ported circuit-breaker-monad to v2, the results are here: https://github.com/YBogomolov/circuit-breaker-monad/tree/feature/fp-ts-v2

If you're interested, here's my feedback about the migration process:

  1. It was quite straightforward, as all I had to do is make the compiler happy.
  2. I was really missing fold/foldL methods. It is a showstopper for both my personal projects and for my work stuff (we are extensively using foldL over RemoteData, Option, Either, etc.). Had I overlooked something? How can I fold, for example, an Either<E, A> into a B using fp-ts@2?
  3. Movement from the class methods to standalone functions seems to be a nice decision from functional perspective, but quite a bummer for "code exploration", as I no longer can see what else can I do with the monad.
gcanti commented 5 years ago

@YBogomolov 2) there's a top level fold function 3) that's definitely a downside. Something like fluent may help here but works with hardcoded type classes at the moment, so you can map, chain, etc... but doesn't provide specialized functions based on the data type at hand (fold or getOrElse for example)

sledorze commented 5 years ago

I know about the priorities:

focus on correctness and DX (in that order)

But with 3, I'm still failing to convince myself that its a good move overall.

cdimitroulas commented 5 years ago

Hope I'm not too late to the party! I just wanted to chime in about some earlier comments on pipe/compose and the inference improvements that have been made in Typescript (@grossbart wrote about this briefly).

It seems that there have been significant improvements in this area with several important issues being closed in TS (https://github.com/microsoft/TypeScript/pull/30114, https://github.com/microsoft/TypeScript/pull/30193 & https://github.com/Microsoft/TypeScript/pull/30215). There are some open issues relating to overloads (https://github.com/microsoft/TypeScript/issues/26591 & https://github.com/microsoft/TypeScript/issues/29732) but those can be avoided by using generic rest parameters as suggested by ahejlsberg here

There is a nice example of well typed pipe/compose here (haven't tested this code yet personally) which suggests that pipe/compose with good inference is currently possible in TS. Personally, I agree with @joshburgess in that I prefer working with data and standalone functions. This style of FP feels more natural to me than the fluent API style.

rzeigler commented 5 years ago

I'm torn on the issue of fluent v loose functions. On the one hand, I generally prefer loose functions relatedly. On the other hand using a fluent style allows a limited form of closing over type parameters in a natural way which results in less wonkiness.

gcanti commented 5 years ago

@sledorze IMO 3) is far from being a show stopper.

In fp-ts@1 the situation is only slightly better:

We could even say that in fp-ts@1 chainable APIs are the exception rather than the rule.

I want a more general solution and fluent is the best candidate so far.

In fp-ts@2 having a chainable API for These is simple as

import { getMonad, right, left, these } from '../src/These'
import { fluent } from '../src/fluent'
import { semigroupString } from '../src/Semigroup'

const M = getMonad(semigroupString)
const wrap = fluent({ ...M, ...these }) // done

// const x: number
const x = wrap(right(1))
  .map(n => n + 1)
  .chain(n => (n > 2 ? right(n + 1) : left('foo')))
  .bimap(s => s + 'a', n => n - 1)
  .reduce(1, (b, a) => b + a)

or for Array

import { array } from '../src/Array'
import { some, none } from '../src/Option'

const wrap = fluent(array)

// const y: Separated<number[], number[]>
const y = wrap([1, 2, 3])
  .filterMap(a => (a > 2 ? some(a + 1) : none))
  .chain(a => [a + 1, a - 1])
  .partition(a => a < 10)
grossbart commented 5 years ago

I have to say these fluent examples are starting to convince me of the new approach 😄

gcanti commented 5 years ago

@grossbart and the implementation of fluent is not even complete, we can add support for Compactable, Ring, Field, Lattice, Bounded, Category, IxMonad...

In this 3D I mostly talked about the downsides so far because I share your concerns and I'm used to tackle the cons before marketing the pros, but there are a lot of benefits in using a "raw" (i.e. unwrapped) encoding for the data types.

1) serialization / deserialization is a breeze

2) no more need for a Validation data type, we can reuse Either and just use a different instance

import { left, right } from '../src/Either'
import { fluent } from '../src/fluent'
import { semigroupString } from '../src/Semigroup'
import { getApplicative } from '../src/Validation'

const wrap = fluent(getApplicative(semigroupString))
console.log(
  wrap(right(1))
    .apFirst(left('a'))
    .apFirst(left('b')).value
) // => left('ab')

3) These is just Either with an additional Both member

export type These<L, A> = Either<L, A> | Both<L, A>

4) no more hacks in sum type definitions like type Either<L, A> = Left<L, A> | Right<L, A>

5) Identity<A> can be actually used as a transparent effect since is now an alias for A

6) monad tansformers are way better now, please take a look at the implementation of Reader or State for example (spoiler: they are just ReaderT[Identity] and StateT[Identity])

7) defining an instance of a new, compound data type is easier:

for example, let's say we want to define a monad instance for ArrayOption

import { array } from '../src/Array'
import { Monad1 } from '../src/Monad'
import { Option } from '../src/Option'
import { getOptionM } from '../src/OptionT'

const T = getOptionM(array)

declare module '../src/HKT' {
  interface URI2HKT<A> {
    ArrayOption: Array<Option<A>>
  }
}

interface ArrayOption<A> extends Array<Option<A>> {}

const monadArrayOption: Monad1<'ArrayOption'> = {
  URI: 'ArrayOption',
  map: T.map,
  of: T.of,
  ap: T.ap,
  chain: T.chain
}

Done.

Ok but where's my chainable API? Just pass monadArrayOption to fluent

const wrap = fluent(monadArrayOption)

console.log(wrap([some(1), none, some(2)]).map(n => n + 1).value) // [some(2), none, some(3)]

8) Const<L, A> is just L + a phantom type

9) functions / combinators are more reusable

For example there's a withTimeout combinator in fp-ts-contrib for Task, and another one for TaskEither.

In fp-ts@2 we can just define withTimeout for Task since TaskEither<L, A> = Task<Either<L, A>>

And probably other benefits that I don't remember now... I'll make sure to list them here when I notice them

gcanti commented 5 years ago

Oh.. and fluent is also useful if you happen to write software in tagless final style

import { Either, fold } from '../src/Either'
import { fluent } from '../src/fluent'
import { HKT } from '../src/HKT'
import { Monad } from '../src/Monad'

// app effect
interface Effect<M, A> extends HKT<M, Either<string, A>> {}

// capabilities
interface MonadStorage<M, V> extends Monad<M> {
  read: (s: string) => Effect<M, V>
  write: (s: string, n: V) => Effect<M, void>
}

// a program in TF
function program<M>(M: MonadStorage<M, number>): Effect<M, void> {
  // yay! I can use chainable APIs here!
  const wrapM = fluent(M)
  return wrapM(M.read('a')).chain(e => fold(e, () => M.write('a', 1), n => M.write('a', n * 2))).value
}

You can see how 9) (i.e. functions / combinators are more reusable) applies here too. When building an instance of MonadStorage for Task

import { task } from '../src/Task'
import { fromOption, rightIO } from '../src/TaskEither'
import { lookup } from '../src/Record'

const storage: Record<string, number> = {}

// since TaskEither<L, A> = Task<Either<L, A>> I can reuse TaskEither's functions
// to build an instance for Task
const monadStorageTask: MonadStorage1<'Task', number> = {
  ...task,
  read: s => fromOption(lookup(s, storage), () => 'not found'),
  write: (s, n) =>
    rightIO(() => {
      storage[s] = n
    })
}

we can reuse TaskEither's functions.

gcanti commented 5 years ago

@cdimitroulas Re: pipe/compose. AFAIK fp-ts's pipe is already well typed, and with ts@3.4.x can also be used with polymophic functions.

I prefer working with data and standalone functions. This style of FP feels more natural to me than the fluent API style

In order to support this style of programming I'm thinking to add two generic functions: lift and flatMap:

let's say I want to define the following (contrived) function without relying on fluent

import { head } from '../src/Array'
import { fluent } from '../src/fluent'
import { option } from '../src/Option'

const pair = <A>(a: A): [A, A] => [a, a]

// f: <A>(as: A[]) => Option<A>
const f = <A>(as: Array<A>) =>
  fluent(option)(head(as))
    .map(pair)
    .chain(head).value

Now if I naively pipe those functions, either it doesn't type check or I get a different function

import { pipe } from '../src/function'

// f: <A>(a: A[]) => Option<Option<A>>
const f = pipe(
  head,
  pair,
  head
) // not the same function

As shown here https://dev.to/gcanti/getting-started-with-fp-ts-functor-36ek we can think of map as lift

function lift<A, B>(f: (a: A) => B): (fa: Option<A>) => Option<B> {
  return fa => option.map(fa, f)
}

and as shown here https://dev.to/gcanti/getting-started-with-fp-ts-monad-6k we can think of chain as flatMap

function flatMap<A, B>(f: (a: A) => Option<B>): (fa: Option<A>) => Option<B> {
  return fa => option.chain(fa, f)
}

Now we can compose those three functions and obtain the same result as with fluent by only using static functions

// f: <A>(a: A[]) => Option<A>
const f = pipe(
  head,
  lift(pair),
  flatMap(head)
)

Those lift and flatMap helpers above are too specific (Option) but if we add two generic functions:

parametrized by a Functor instance (respectively a Chain instance), we obtain a generic way to build programs by piping functions

const f = pipe(
  head,
  lift(option)(pair),
  flatMap(option)(head)
)

p.s.

lift is already defined in fp-ts@1

export function lift<F extends URIS3>(
  F: Functor3<F>
): <A, B>(f: (a: A) => B) => <U, L>(fa: Type3<F, U, L, A>) => Type3<F, U, L, B>
export function lift<F extends URIS3, U, L>(
  F: Functor3C<F, U, L>
): <A, B>(f: (a: A) => B) => (fa: Type3<F, U, L, A>) => Type3<F, U, L, B>
export function lift<F extends URIS2>(
  F: Functor2<F>
): <A, B>(f: (a: A) => B) => <L>(fa: Type2<F, L, A>) => Type2<F, L, B>
export function lift<F extends URIS2, L>(
  F: Functor2C<F, L>
): <A, B>(f: (a: A) => B) => (fa: Type2<F, L, A>) => Type2<F, L, B>
export function lift<F extends URIS>(F: Functor1<F>): <A, B>(f: (a: A) => B) => (fa: Type<F, A>) => Type<F, B>
export function lift<F>(F: Functor<F>): <A, B>(f: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B>
export function lift<F>(F: Functor<F>): <A, B>(f: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B> {
  return f => fa => F.map(fa, f)
}

so we just need to define flatMap

cdimitroulas commented 5 years ago

Thanks for your response and detailed examples gcanti. What you are saying makes sense and looks like it would enable this style of programming nicely which is great.

Indeed pipe from fp-ts is working well already, we have been using this quite a bit without issues since ts 3.4.

gcanti commented 5 years ago

Ok, just pushed lift and flatMap to v2 (and v2-lib)

As curiosity pipe can be implemented via fluent using the functor instance of Reader (or its Semigroupoid instance, it's the same) as in v2 Reader<E, A> is just an alias of (e: E) => A.

import { fluent } from '../src/fluent'
import { reader } from '../src/reader'

const len = (s: string) => s.length
const double = (n: number) => n * 2
const gt2 = (n: number) => n > 2

// f: Reader<string, boolean>
const f = fluent(reader)(len)
  .compose(double)
  .compose(gt2).value

However it soon becomes awkward when working with polymorphic functions, so pipe is the way to go.

joshburgess commented 5 years ago

Why name them lift and flatMap for these two things versus just sticking to the static land naming convention?

cdimitroulas commented 5 years ago

What is the static land naming convention? I was under the impression lift and chain (rather than flatMap) are the standard terms used in JS

joshburgess commented 5 years ago

@cdimitroulas just map and chain, I think.

gcanti commented 5 years ago

So my plan is:

1) freeze v1 development 2) release fp-ts@2.0.0-rc.1 3) upgrade the ecosystem (monocle-ts, io-ts, etc...) 4) think about how to make easier the migration

rzeigler commented 5 years ago

Cool. I think I will probably release waveguide@0.4 in the next day or do and since I expected 0.5 to take a little longer I can also roll in the fp-ts@2 dependency at that time.

YBogomolov commented 5 years ago

@gcanti Would you be so kind to tell your plans regarding v1? How long it'll be supported?

gcanti commented 5 years ago

@YBogomolov what do you mean? Bug fix? ( this is easy, fp-ts@1 is bug free.. :-P ). Backports from v2?

YBogomolov commented 5 years ago

Backports from v2?

Mostly them, yes. Plus for how long will it be valid to create issues for v1?

leemhenson commented 5 years ago

I think for easier migration of v1 -> v2 codebases, offering v1-equivalent aliases for the functions lift and flatMap might be a good idea:

// from v1 fluent style
myTE
  .map(fx)
  .chain(fy)

// this is a big cognitive jump
pipe(
  lift(taskEither)(fx),
  flatMap(taskEither)(fy)
)

// this is less scary
pipe(
  map(taskEither)(fx),
  chain(taskEither)(fy)
)

If you consider sticking another common fn, e.g. mapLeft in the middle, it's quite jarring:

pipe(
  map(taskEither)(fx),
  te => taskEither.mapLeft(te, fz),
  chain(taskEither)(fy)
)

It would seem a good idea to offer pipeable versions of all the common flow control fns. For example:

import { URIS3, Type3, URIS2, Type2, HKT, HKT2 } from "fp-ts/lib/HKT";
import { Bifunctor3, Bifunctor2, Bifunctor2C, Bifunctor } from "fp-ts/lib/Bifunctor";

export function mapLeft<F extends URIS3>(
  F: Bifunctor3<F>,
): <L, M>(f: (l: L) => M) => <U, A>(fa: Type3<F, U, L, A>) => Type3<F, U, M, A>;
export function mapLeft<F extends URIS2>(
  F: Bifunctor2<F>,
): <L, M>(f: (l: L) => M) => <A>(fla: Type2<F, L, A>) => Type2<F, M, A>;
export function mapLeft<F extends URIS2, L>(
  F: Bifunctor2C<F, L>,
): <L, M>(f: (l: L) => M) => <A>(fla: Type2<F, L, A>) => Type2<F, M, A>;
export function mapLeft<F>(
  F: Bifunctor<F>,
): <L, M>(f: (l: L) => M) => <A>(fla: HKT2<F, L, A>) => HKT2<F, M, A>;
export function mapLeft<F>(
  F: Bifunctor<F>,
): <L, M>(f: (l: L) => M) => <A>(fla: HKT2<F, L, A>) => HKT2<F, M, A> {
  return f => fla => F.mapLeft(fla, f);
}

Which gives:

pipe(
  map(taskEither)(fx),
  mapLeft(taskEither)(fz),
  chain(taskEither)(fy)
)

Not too bad. Could we also offer specialized exports for the common typeclasses? E.g.

import { map, mapLeft, chain } from "fp-ts/lib/specialised/TaskEither";

// e.g. export const map = unspecialised.map(taskEither);
// e.g. export const mapLeft = unspecialised.mapLeft(taskEither);
// e.g. export const chain = unspecialised.chain(taskEither);

pipe(
  map(fx),
  mapLeft(fz),
  chain(fy)
)

That gets you really close to the fluent interface, which would be great considering this just happened: https://github.com/microsoft/TypeScript/pull/31377

gcanti commented 5 years ago

@leemhenson we could ditch lift and flatMap in favour of a more complete pipeable.ts module, along the lines of fluent.ts, i.e. a module that can be configured with type class instances.

POC

// pipeable.ts

import { Bifunctor2 } from './Bifunctor'
import { Chain2 } from './Chain'
import { Type2, URIS2 } from './HKT'
import { identity } from './function'

export interface PipeableChain2<F extends URIS2> {
  readonly map: <L, A, B>(f: (a: A) => B) => (fa: Type2<F, L, A>) => Type2<F, L, B>
  readonly chain: <L, A, B>(f: (a: A) => Type2<F, L, B>) => (ma: Type2<F, L, A>) => Type2<F, L, B>
  readonly flatten: <L, A>(mma: Type2<F, L, Type2<F, L, A>>) => Type2<F, L, A>
}

export interface PipeableBifunctor2<F extends URIS2> {
  readonly bimap: <L, A, M, B>(f: (l: L) => M, g: (a: A) => B) => (fa: Type2<F, L, A>) => Type2<F, M, B>
  readonly mapLeft: <L, A, M>(f: (l: L) => M) => (fa: Type2<F, L, A>) => Type2<F, M, A>
}

export function getPipeableChain<F extends URIS2>(F: Chain2<F>): PipeableChain2<F> {
  return {
    map: f => fa => F.map(fa, f),
    chain: f => ma => F.chain(ma, f),
    flatten: mma => F.chain(mma, identity)
  }
}

export function getPipeableBifunctor<F extends URIS2>(F: Bifunctor2<F>): PipeableBifunctor2<F> {
  return {
    bimap: (f, g) => fa => F.bimap(fa, f, g),
    mapLeft: f => fa => F.mapLeft(fa, f)
  }
}

Usage

import { taskEither, TaskEither } from './TaskEither'
import { pipe } from './function'

// static functions à la carte
const { map, chain, mapLeft, bimap } = {
  ...getPipeableChain(taskEither),
  ...getPipeableBifunctor(taskEither)
}

declare function readFile(path: string): TaskEither<string, string>
declare function len(s: string): number
declare function gt2(n: number): TaskEither<string, boolean>
declare function double(n: number): number

// f: (a: string) => TaskEither<boolean, number>
const f = pipe(
  readFile,
  map(len),
  chain(gt2),
  bimap(s => s.length, b => (b ? 0 : 1)),
  map(double),
  mapLeft(n => n > 0)
)

Note that, like fluent, pipeable will work with any instance (Validation, Writer, etc..)

Once they are stable, we can backport fluent and pipeable to v1.

So, depending on the version and the preferred style, we can choose among:

1) v1 (methods)

myTE
  .map(fx)
  .chain(fy)

2) v1 / v2 (instances)

M.chain(M.map(myTE, fx), fy)

3) v1 / v2 (fluent)

wrap(myTE)
  .map(fx)
  .chain(fy)
  .value

4) v1 / v2 (pipeable)

pipe(
  map(fx),
  chain(fy)
)
leemhenson commented 5 years ago

Neat!

sledorze commented 5 years ago

@gcanti Great! :)

raveclassic commented 5 years ago

@gcanti Awesome!

So, depending on the version and the preferred style, we can choose among:

So we end up with 4 different APIs in fp-ts@1.x and 3 in fp-ts@2.x. Do we really need them all? I had a hard time with my team dealing with choosing between methods and instance members and this change is going to complicate things even more. Shouldn't we choose something one, ship it with the core and move all the rest to fp-ts-contrib when releasing fp-ts@2.0?

gcanti commented 5 years ago

@raveclassic I'm torn on this: on one hand fluent and pipeable should go in fp-ts-contrib, on the other hand.. is fp-ts@2 "usable" without them?

So, folks, what do you think?

leemhenson commented 5 years ago

Is fluent viable given https://github.com/microsoft/TypeScript/pull/31377 ? It sounds like it won't be filtering accurately any more. For example, with typescript@3.5.0-dev.20190515:

import { fluent } from "fp-ts/lib/Fluent";
import { option, Option, some, none } from "fp-ts/lib/Option";

const fO = fluent(option);

fO(none).filterWithIndex(...)   // <- filterWithIndex appears in intellisense but should only be visible for typeclasses that have FilterableWithIndex, which Option does not.

Unless I'm misunderstanding the mechanism, this looks fatal for fluent in it's current form.

gcanti commented 5 years ago

@leemhenson it affects the DX, the code won't type check

giogonzo commented 5 years ago

So, folks, what do you think?

@gcanti as already discussed offline, I wouldn't consider fp-ts@2 "usable" the same as v1 is today with only the "instances" API, especially considering the newbie perspective and how to sell fp-ts and FP in general to existing teams/projects:

M.chain(
  M.map(
    M.chain(
      myTE,
      f
    ),
    g
  ),
  h
)

is not readable enough (scattered business logic).

"pipeable" seems promising, I still have to experiment with it but as of today I'd vote to include this API as part of the core, and fluent into fp-ts-contrib (especially considering the recently degraded DX).

Personally, I wouldn't really care where my imports are from (core or contrib), and I'd probably pick what I think works best from case to case. But for someone approaching the API for the first time, having a "suggested" path makes a lot of difference I think.

gcanti commented 5 years ago

@giogonzo there's still a missing bit in order to make pipe usable and make the migration easier.

Let's say we have the following code in v1

declare const myTE: TaskEither<Error, string>
declare function readFile(path: string): TaskEither<Error, string>
declare function writeFile(path: string, content: string): TaskEither<Error, void>

const result = myTE
  .chain((path: string) => readFile(path + '.txt'))
  .map(content => 'header' + content)
  .chain(modifiedContent => writeFile('out.txt', modifiedContent))

Now we can migrate to

import { pipe } from '../src/function'
import { pipeable } from '../src/pipeable'

const { map, chain } = pipeable(taskEither)

const result2 = pipe(
  chain((path: string) => readFile(path + '.txt')),
  map(content => 'header' + content),
  chain(modifiedContent => writeFile('out.txt', modifiedContent))
)(myTE)

There are two awkward things though:

As suggested by @grossbart here we can add a pipeOf function that accepts a value instead of a function as first parameter

export function pipeOf<A, B, C>(a: A, ab: (a: A) => B): B
export function pipeOf<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C
export function pipeOf<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D
// etc...

So this

const result = myTE
  .chain((path: string) => readFile(path + '.txt'))
  .map(content => 'header' + content)
  .chain(modifiedContent => writeFile('out.txt', modifiedContent))

becomes this

const result2 = pipeOf(
  myTE,
  chain(path => readFile(path + '.txt')),
  map(content => 'header' + content),
  chain(modifiedContent => writeFile('out.txt', modifiedContent))
)
YBogomolov commented 5 years ago

IMO, for purely functional interface having the value last is better, as it allows for easy currying and partial application afterwards. Look at Ramda.js for example of nice pipeable/composable interface.

gcanti commented 5 years ago

@YBogomolov not sure I'm following, pipeOf is just pipe followed by a function application.

Indeed we can add apply instead of pipeOf

export function apply<A, B>(a: A, f: (a: A) => B): B {
  return f(a)
}

const result = apply(
  myTE,
  pipe(
    chain(path => readFile(path + '.txt')),
    map(content => 'header' + content),
    chain(modifiedContent => writeFile('out.txt', modifiedContent))
  )
)
gcanti commented 5 years ago

@leemhenson we could change some APIs and make them data last, for example Option.fold:

from

export function fold<A, R>(ma: Option<A>, onNone: () => R, onSome: (a: A) => R): R {
  return isNone(ma) ? onNone() : onSome(ma.value)
}

to

export function fold<A, R>(onNone: () => R, onSome: (a: A) => R): (ma: Option<A>) => R {
  return ma => isNone(ma) ? onNone() : onSome(ma.value)
}

it seems to be beneficial to both pipeable and fluent:

Example

Original code (v1)

some(1)
  .map(n => n * 2)
  .chain(n => (n > 2 ? some(n) : none))
  .map(n => n + 1)
  .foldL(() => 'none', a => `some(${a})`)

With pipeable

import { pipeable, pipe, apply } from '../src/pipeable'
import { option, some, none, fold } from '../src/Option'

const O = pipeable(option)

apply(
  some(1),
  pipe(
    O.map(n => n * 2),
    O.chain(n => (n > 2 ? some(n) : none)),
    O.map(n => n + 1),
    o => fold(o, () => 'none', a => `some(${a})`) // <= this could simply be `fold(() => 'none', a => `some(${a})`)`
  )
)

With fluent

import { fluent } from '../src/fluent'

const wrap = fluent(option)

wrap(some(1))
  .map(n => n * 2)
  .chain(n => (n > 2 ? some(n) : none))
  .map(n => n + 1)
  .apply(o => fold(o, () => 'none', a => `some(${a})`)) // <= this could simply be `fold(() => 'none', a => `some(${a})`)`
giogonzo commented 5 years ago

o => fold(o, () => 'none', a => some(${a})) // <= this could simply be fold(() => 'none', a =>some(${a}))

but then explicit annotations on the functions passed to onNone/onSome would be required, correct?

gcanti commented 5 years ago

@giogonzo no, TypeScript is able to infer the correct type there

inference
sledorze commented 5 years ago

@gcanti we should make sure that this inference scheme will not be deprecated. We've been beaten in the past with inference regression / changes and if we adopt that style and it gets deprecated, we would be in trouble. fp-ts being part of RWC in typescript tests, we should take the opportunity to trigger the TS team to get some feedback on that matter.

gcanti commented 5 years ago

@sledorze the API I'm proposing (i.e. pipeOp = apply . pipe, a kind of faked pipe operator) works with old versions too, specifically with typescript@2.4.1+

Also:

Here's a standalone repro (please can anyone confirm?)

export type Option<A> = { _tag: 'None' } | { _tag: 'Some'; value: A }
const none: Option<never> = { _tag: 'None' }
const some = <A>(a: A): Option<A> => ({ _tag: 'Some', value: a })
const map = <A, B>(f: (a: A) => B) => (ma: Option<A>): Option<B> => (ma._tag === 'None' ? none : some(f(ma.value)))
const chain = <A, B>(f: (a: A) => Option<B>) => (ma: Option<A>): Option<B> => (ma._tag === 'None' ? none : f(ma.value))
const fold = <A, R>(onNone: () => R, onSome: (a: A) => R) => (ma: Option<A>): R =>
  ma._tag === 'None' ? onNone() : onSome(ma.value)

function pipeOp<A, B>(a: A, ab: (a: A) => B): B
function pipeOp<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C
function pipeOp<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D
function pipeOp<A, B, C, D, E>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D, de: (d: D) => E): E
function pipeOp(a: any, ...fns: Array<Function>): Function {
  let r: any = a
  for (let i = 0; i < fns.length; i++) {
    r = fns[i](r)
  }
  return r
}

const head = <A>(as: Array<A>): Option<A> => (as.length > 0 ? some(as[0]) : none)

const result = pipeOp(
  some(10),
  chain(n => (n > 2 ? some(n) : none)),
  map(n => [n]),
  chain(head), // works with polymorphic functions
  fold(() => 'none', a => `some(${a})`) // works with curried functions
)

console.log(result) // some(10)

(*)

pipeOp(
  x,
  f,
  g,
  h
)

instead of

apply(
  x,
  pipe(
    f,
    g,
    h
  )
)
patroza commented 5 years ago

I'm moving in a similar direction with a project after discovering neverthrow and fp-ts after investigating F# and functional domain modelling, and running into the long chains and indentations in typescript.

Have you considered also Promise support for the pipe? I think for me the hardest was to come up with an API that supports both, async and sync code without doubling up the methods, and still being type-safe. And to be able to insert the Promise anywhere in the chain (start, mid, end). Ah, but I reckon it is easier in fp-ts due to Task.

I've added a .pipe method to Result, and to Promise. As soon as im mixing sync with async, I need to start the pipe from a Promise, other than that it's been pretty good so far.

I also landed at a pipe that can be created out of thin air, instead of having to be chained on top of a Result or Promise.

Very similar to this that was posted in https://github.com/gcanti/fp-ts/issues/823#issuecomment-492240744:

// f: (a: string) => TaskEither<boolean, number>
const f = pipe(
  readFile,
  map(len),
  chain(gt2),
  bimap(s => s.length, b => (b ? 0 : 1)),
  map(double),
  mapLeft(n => n > 0)
)

I implemented versions of tee (don't care about the output, just pass the input) and tuple (pass both the input and output on, as a tuple), and then ended up with need to be able to flatten tuples when I needed to pass on the ouput of 3 values, not to end up with [[out1, out2], out3] etc :) it helps to make:

pipe(
  doX(),
  flatMap(x =>
    flatMap(doY(x).pipe(
      flatMap(y => doZ(y).pipe(
        flatMap(z => doWithXYZ(x, y, z)),
       ))
    ))
  )
)

into:

pipe(
  flatMap(doX),
  flatMap(tup(doY)),
  flatMap(flatTup(doZ)),
  map(doWithXYZ),
)

I also like the idea of pipeOp. Really interesting stuff, will be following this project from now on :)

I was also looking into Generators, to kind of implement something similar as async {, result { and asyncResult { Computation Expressions in F# But typescripts type system doesn't make that pretty yet :) hopefully the TS changes land soon. It leads to more imperative code, but sometimes that is useful.

cdimitroulas commented 5 years ago

@patroza if you switch to using Task/TaskEither then you can avoid worrying about Promises until you need to get your final result

patroza commented 5 years ago

@patroza if you switch to using Task/TaskEither then you can avoid worrying about Promises until you need to get your final result

thanks, will dive into it, had not so much luck so far; is there a sample how to do this in the current released API;