tc39 / proposal-pipeline-operator

A proposal for adding a useful pipe operator to JavaScript.
http://tc39.github.io/proposal-pipeline-operator/
BSD 3-Clause "New" or "Revised" License
7.51k stars 108 forks source link

Hack pipes and TypeScript inference of curried functions #219

Open js-choi opened 2 years ago

js-choi commented 2 years ago

Spinning this out of tc39/proposal-hack-pipes#18 (thanks @OliverJAsh). He raised an important point: that rx.pipe, etc. currently work with but would not work with Hack pipes (or F# pipes?).

We need to document this in the explainer in an FAQ.

To be clear, this is not due to any inherent mathematical property of Hack pipes.

This is a manifestation of a limitation of TypeScript’s current inference algorithm: it can only unify types from left to right, and it cannot unify types in expressions like map(i => i + 1)(items).

This is an incidental TypeScript limitation, about which @OliverJAsh has been raising issues for several years (see microsoft/TypeScript#15680, as well as the related microsoft/TypeScript#22081, microsoft/TypeScript#25826, microsoft/TypeScript#29904, microsoft/TypeScript#30134).

Indeed, this limitation is why RxJS and Ramda must use a brittle workaround for their pipe functions: they manually overload the arity of their pipe functions up to a finite number. For example, RxJS manually overloads the arity of rx.pipe ten times:

export function pipe(): typeof identity;
export function pipe<T, A>(fn1: UnaryFunction<T, A>): UnaryFunction<T, A>;
export function pipe<T, A, B>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, B>): UnaryFunction<T, B>;
export function pipe<T, A, B, C>(fn1: UnaryFunction<T, A>, fn2: UnaryFunction<A, B>, fn3: UnaryFunction<B, C>): UnaryFunction<T, C>;
export function pipe<T, A, B, C, D>(
  fn1: UnaryFunction<T, A>,
  fn2: UnaryFunction<A, B>,
  fn3: UnaryFunction<B, C>,
  fn4: UnaryFunction<C, D>
): UnaryFunction<T, D>;
export function pipe<T, A, B, C, D, E>(
  fn1: UnaryFunction<T, A>,
  fn2: UnaryFunction<A, B>,
  fn3: UnaryFunction<B, C>,
  fn4: UnaryFunction<C, D>,
  fn5: UnaryFunction<D, E>
): UnaryFunction<T, E>;
export function pipe<T, A, B, C, D, E, F>(
  fn1: UnaryFunction<T, A>,
  fn2: UnaryFunction<A, B>,
  fn3: UnaryFunction<B, C>,
  fn4: UnaryFunction<C, D>,
  fn5: UnaryFunction<D, E>,
  fn6: UnaryFunction<E, F>
): UnaryFunction<T, F>;
export function pipe<T, A, B, C, D, E, F, G>(
  fn1: UnaryFunction<T, A>,
  fn2: UnaryFunction<A, B>,
  fn3: UnaryFunction<B, C>,
  fn4: UnaryFunction<C, D>,
  fn5: UnaryFunction<D, E>,
  fn6: UnaryFunction<E, F>,
  fn7: UnaryFunction<F, G>
): UnaryFunction<T, G>;
export function pipe<T, A, B, C, D, E, F, G, H>(
  fn1: UnaryFunction<T, A>,
  fn2: UnaryFunction<A, B>,
  fn3: UnaryFunction<B, C>,
  fn4: UnaryFunction<C, D>,
  fn5: UnaryFunction<D, E>,
  fn6: UnaryFunction<E, F>,
  fn7: UnaryFunction<F, G>,
  fn8: UnaryFunction<G, H>
): UnaryFunction<T, H>;
export function pipe<T, A, B, C, D, E, F, G, H, I>(
  fn1: UnaryFunction<T, A>,
  fn2: UnaryFunction<A, B>,
  fn3: UnaryFunction<B, C>,
  fn4: UnaryFunction<C, D>,
  fn5: UnaryFunction<D, E>,
  fn6: UnaryFunction<E, F>,
  fn7: UnaryFunction<F, G>,
  fn8: UnaryFunction<G, H>,
  fn9: UnaryFunction<H, I>
): UnaryFunction<T, I>;
export function pipe<T, A, B, C, D, E, F, G, H, I>(
  fn1: UnaryFunction<T, A>,
  fn2: UnaryFunction<A, B>,
  fn3: UnaryFunction<B, C>,
  fn4: UnaryFunction<C, D>,
  fn5: UnaryFunction<D, E>,
  fn6: UnaryFunction<E, F>,
  fn7: UnaryFunction<F, G>,
  fn8: UnaryFunction<G, H>,
  fn9: UnaryFunction<H, I>,
  ...fns: UnaryFunction<any, any>[]
): UnaryFunction<T, unknown>;

As can be seen, this means that rx.pipe only supports automatic TypeScript inference when piping a value through less than nine functions; more than ten functions would require manual typing.

Again, this is not due to any inherent mathematical property of Hack pipes. In fact, this probably would not work with F# pipes either. It is the result of not being able override the arity of a pipe operator and to specify the type of each arity, as RxJS does above (and as Ramda, etc. do too).

The amazing TypeScript team has been working hard on a new, more robust type inferencer: one that improves its type unification so that it retains free types (microsoft/TypeScript#30134). But of course this means that we can’t do map(i => i + 1)(items) today, and we therefore can’t do items |> map(i => i + 1)(^) today. (I’m sorry that Hack pipes don’t give a good answer to this now—other than to manually annotate types as needed (which TypeScript developers often already have to do) and hope for microsoft/TypeScript#30134 to land.)

In any case, the explainer does not talk about this. This is a deficiency of the explainer. We need to fix this sometime.

This issue tracks the fixing of this deficiency in the explainer (lack of discussion regarding TypeScript’s current limitations). Please try to keep the issue on topic (e.g., comments about the importance of tacit programming would be off topic), and please try to follow the code of conduct (and report violations of others’ conduct that violates it to tc39-conduct-reports@googlegroups.com). Please also try to read CONTRIBUTING.md and How to Give Helpful Feedback. Thank you!

acutmore commented 2 years ago

If it's useful to anyone. The following utility function can wrap a function so that the receiver can be passed as the first argument, which currently works better for TypeScript inference.

function uncurryReceiver<Args extends any[], R, O>(
    op: (...args: Args) => (receiver: R) => O
) {
    return function(receiver: R, ...args: Args): O {
        return op(...args)(receiver);
    }
}

Example usage:

function map<V, R>(f: (v: V) => R) {
    return function(arr: Array<V>): Array<R> {
        return arr.map(f);
    }
}

map(v => v + 1)([1, 2, 3]);   // no inference, 'v' is 'unknown' ❌

const _map = uncurryReceiver(map);

_map([1, 2, 3], v => v + 1);  // 'v' is inferred as 'number' ✅

https://www.typescriptlang.org/play?#code/GYVwdgxgLglg9mABOCIBOaCeAlAphXGAN1zQB4BBNAcwGdFcAPKXMAE3oEMxMBtAXQA0ibMIDyAPgAUAWABQASDgAHAFyIpAOm2catdVToBKRAF4JGtPkIk067CfOIx8kwG95Cq1HRJQkWAQpKwJiUnthbU1dOgM9I3UxRA9FL1wfNCQVLR144OswtCMAbk8AX3kKuXl-aHgkAFtOZTIANWFsaWB1KSJ1VscLB2TPb19EWsCwXri0Tkw2iQTEKjmFzpHUsczEIk0m5SlgEvLK+XkD3rMLIkQAakQARiMpXkfhACZhAGZ+EsQAQB6QGIMBwRAwMDAUgwyC4YQAciICIh9AR4AA1mCAO5gFGIQAy5Oc5BAELQoIgAPoHMzISDoLB4UK2KQHE7VOTU5qvd6IL6IX7CW5OW4PZ7FIEgpEomD0SHQjC4NiIThosAgBoAI1IKMAoOTyIA

ken-okabe commented 2 years ago

Related topic: Inconsistency of Type of the operator #227 Fundamentally this is the math issue, with F# pipe, the problem will not occur.

js-choi commented 2 years ago

Mm, #227 is about a different topic. This topic is merely about documenting tc39/proposal-hack-pipes#18, which is just the fact that TypeScript currently already cannot automatically infer the types of curried function calls like map(f)(x) without a manually declared pipe function (as above, see the definition of RxJS’s pipe function, which manually declares its types ten times for ten arities).

So just like how TypeScript already cannot automatically infer the type of map(f)(x), it cannot infer the type of x |> map(f)(^). But it is able to infer the type of map(x, f), and so it would also be able to infer the type of x |> map(^, f). This problem isn’t something about the Hack pipe. The problem is about TypeScript and curried function calls, which has always existed (as above, see microsoft/TypeScript#15680, microsoft/TypeScript#22081, microsoft/TypeScript#25826, microsoft/TypeScript#29904, microsoft/TypeScript#30134).

Anyways, both of these comments are off topic, so I will mark them as such. ^_^