tc39/proposal-function-pipe-flow

flow/compose when given no input functions

Closed this issue · 15 comments

Let’s assume that the Committee approves Function.flow and/or Function.compose (#5).

flow() by default returns an identity function, so flow()(1) === 1.

But should flow() === flow()`?
Should there be one identity function instance or should it be constructed each time? The specification must decide.

Same question for compose, if it gets approved. And in the event that the Committee approves both, then we must also decide whether flow() === compose().

I’m currently inclined to make the answer yes (return the same identity-function instance every time).

Will it be different cross realm, or somehow identical? Cross-realm identity could be established with a special case in the SameValueStrict algorithm, I think.

If we have to return an identity function, I think we should promote it to Function.identity. Lots of FP libs and code bases define an identity function, so there'd be some small benefit in just providing this as a first-class value that anyone can use.


Also, why do we have to do this? Why couldn't it just throw if you pass it no functions?

Why couldn't it just throw if you pass it no functions?

Yes, this is also an option.
I imagine that a single identity function instance would probably differ across realms; I don’t have a strong opinion about this.

I got somewhat strong pushback from the Committee when I proposed Function.identity, for what it is worth. I was holding out hope for a re-try at proposing Function.identity and returning it from flow(), and I wanted to keep that path open. But throwing would also be forward compatible with it too.

I got somewhat strong pushback from the Committee when I proposed Function.identity

It's a bit more optimistic than that though, right? Looks like the pushback was more to do with Function.identity being part of a proposal without a "problem statement", which I guess is another way of saying it was a bit too unfocussed.

This new proposal seems well focussed. Perhaps adding Function.identity now would dilute that? Perhaps a future separate proposal for just that helper would be lubricated by having this new flow/pipe one moving forward a stage or two?

There was specific pushback about Function.identity, particularly the benefit of adding identity versus having developers use x => x every time. For instance, @FUDCo commented:

Quotation about Function.identity

CM: So one of our [main] concerns is always about adding complexity into the language, into the spec, and how hard it is to learn as the body of stuff that is in there gets bigger and bigger. So I think it’s a little weird to me to be having things that are easier to just code, easier than the cognitive overhead of learning that they're even in the language in the first place. [For] some of the things like uncurryThis, you know, there are subtleties that actually having somebody make sure you get that right has some value. But something like identity—it seems like you’re adding complexity for relatively small incremental benefit and I don't think it pays for itself. I think this goes hand in hand with the comments that a number of people have made about unbundling this into separate proposals, because I think some of these things could be quite useful and I think some of them would be a burden.

In any case, we’ll have to decide between these choices if flow or compose gets support from the Committee.

  1. flow() returns a new identity function instance every time.
  2. flow() returns the same identity function instance every time (within a single realm).
  3. flow() throws.

Thanks for the quote, I did miss that.

I agree with the point in principle, but not really in practice.

Practically speaking, just how much cognitive baggage does Function.identity really add? There are whole areas of MDN that just have no relevance for me whatsoever in my own day-to-day, but I appreciate that are a class of problems someone else is dealing with that are being addressed by them.

In the case of Function.identity, for those who this is not relevant I'd argue it is exactly as opaque as x => x. If such a person were asked to judge the feature after having it explained to them, perhaps they would indeed wonder at the unnecessary additional complexity.

But I get the feeling that those living in FP/ADT land in a JavaScript way are somewhat eager to take whatever efficiency gains they can. A single-realm Function.identity would be a small, but not insignificant gain in that sense. Even if it were not introduced in this very proposal, perhaps it would be bolstered by it later on in another.

So option 2 for me please. 😁

I don't feel like it makes sense to always return the same identity function each time, it just goes against my intuition of how I would expect it to behave.

Function.flow(), under normal circumstances will return a new function. I would expect that when you provide no arguments to this function, if we're not wanting that to throw, then we should try and have it match as closely as possible to the argument-passing behavior. no-arguments is supposed to be a special-case of the argument-passing behavior, not a new case that's treated differently.

If we're not throwing an exception in an exceptional circumstance, how do we know it's an exception if the result is volatile?

let ex = Function.flow();
assert(ex === Function.identity);

If you’re talking about unit testing, you would probably assert on the returned function’s own return value:

assert(flow()(1) === 1);

Or, you can check before you call the function.

if (fns.length === 0) {
  // handle it
}
const result = Function.flow(...fns);

A specific means to check if the result is an exceptional value isn't necessary if it's just as easy to do the check before calling the function.

Question... since we're not applying the "unary"ness to the output function, should we just define the base case (fns.length <= 1) as "return whatever function you passed, or undefined if nothing"?

var a = Function.flow();
a;  // undefined

var f = x => x * 2;
var b = Function.flow( f );
b === f;   // true

I like that @getify. undefined is basically going in, so you get undefined out.

@getify: As an educator, have you observed any novices trying using a compose function (either LTR or RTL) with no arguments—or doing something like compose(...arrayOfFnsThatActuallyIsEmpty)? Do you have a feel for what expectations they might have?

Otherwise, I do feel that most precedents from FP languages and libraries (including those with n-ary functions) would have compose() return an identity function, and not matching that may surprise developers who are used to those precedents. But I need to take the time to actually create a table of precedents and their behaviors. I’ll do that if I succeed at advancing to Stage 1 next week.

@js-choi

As it relates to the one argument case, I think it's pretty obvious that you don't need a different function back than the one you passed in, so my instinct is that wouldn't be surprising behavior at all, even for new learners, to just get the function back. That also happens to be the most performant behavior, I think.

But as it relates to the zero argument case, my thoughts are more complicated. Conceptually, it's certainly more graceful to degrade to the identity function case. But that also sort of hides the problem, if it was a mistake that there were no arguments passed in. I have seen students be very frustrated (and quite honestly, very experienced devs the same!) when they think some function is doing something, and it turns out it's a different function than they assumed.

In that sense, it'd be far better if it was a bit noisier that you tried to do a composition with no functions. That could be an exception thrown explicitly, or the more "FP" way is to avoid an exception but return a value that you can clearly see is NOT what you wanted. We're not doing monads (maybe/option/either) here, which would be the most FP approach, so returning undefined seems like the next best FP-friendly option. If someone doesn't check it, and they try to invoke undefined, they'll get the infamous (and not super helpful) "undefined is not a function" exception, but at least they'll know there was a problem with the composition rather than thinking the function is something that it's not.

OTOH, are there any cases where I want a composition to degrade to an identity pass through? As a default function parameter, maybe:

function something(processor = flow()) {
   // ..
   return processor( someVal );
}

It's kind of fun and clever to have that terse default. But thinking as an FP person, I think I'd rather avoid using a default/hidden behavior, and be more explicit:

function something(processor = flow(v=>v)) {
   // ..
   return processor( someVal );
}

That sort of approach feels more FP to me. And I definitely think it'd be more inline with how I would teach students on the topic. Even if flow(..) did default to identity, I think learners should probably be taught to avoid relying on that, if for no other reason than more explicit code is often easier to learn from, and spot mistakes in.

should we just define the base case (fns.length <= 1) as "return whatever function you passed

This is a good point, I think it makes sense to return the same function that was passed in. Which destroys my argument for wanting a different identity function returned every time you call flow() with no arguments.

As to what the expected behavior should be when flow() is called with no arguments, I would certainly never expect someone to explicitly call flow() with no arguments, or even with one argument. It's only really useful if it's called with multiple arguments. The only reason why it's useful to define behaviors for these scenarios is when flow() gets called with a variable number of arguments, so perhaps it would be useful to think of some scenarios of why you might use a variable number of arguments.

One potential reason is because you want to create a sort of plugin system, allowing people to modify how something behaves, like this:

const resultModifiers = [];

export function addResultModifier(fn) {
  resultModifiers.push(fn);
}

export function doTask () {
  return flow(...resultModifiers)(theResult);
}

In this example, if flow() gracefully degrages to an identity function when no arguments are passed in, then this will just work. If it doesn't, we'd have to write that function like this:

export function doTask () {
  if (resultModifiers.length === 0) {
    return theResult;
  } else {
    return flow(...resultModifiers)(theResult);
  }
}

Which, to your point, this second example, while more verbose, does explicitly state how we want the edge case to be handled, so it's crystal clear that we were thinking about this scenario when building this function and are expecting it to behave just like that. So, I guess it's a matter of preference.

Due to its rejection for Stage 1 on 2022-07, I’m withdrawing this proposal. I really appreciate everyone here giving discussion, though. (In the far future, after the pipe operator gains users, pain points with the pipe operator may be enough motivation to revive this proposal.) Happy trails to all.