proposal-pipeline-operator icon indicating copy to clipboard operation
proposal-pipeline-operator copied to clipboard

Optional chaining support for the pipeline operator?

Open dead-claudia opened this issue 6 years ago • 48 comments

Edit: Fix minor semantic mistake

It's not an uncommon occurrence to want to do something like this:

const temp = value?.foo?.bar
const result = temp != null ? doSomethingWithValue(temp, bar) : undefined

If support for optional chaining was added, this could be simplified to just this, providing some pretty massive wins in source size (I'm using Elixir-style syntax here, but you could translate it to others easily enough):

const result = value?.foo?.bar?|>doSomethingWithValue(bar)
Here's what they'd look minified, if you're curious
var t=v?.foo?.bar,r=null!=t?f(t,b):void 0 // Original
var r=v?.foo?.bar?|>f(b)                  // With my suggestion here

dead-claudia avatar Nov 15 '19 17:11 dead-claudia

I think that this should be a separate proposal, with a different token (e.g. ?>)

nicolo-ribaudo avatar Nov 15 '19 17:11 nicolo-ribaudo

I'm not tied to the syntax - I was just using it as an example. Admittedly, it's a bit ugly.

I think that this should be a separate proposal

It seems orthogonal in the way private fields are orthogonal to public fields, so I'm not convinced.

dead-claudia avatar Nov 15 '19 17:11 dead-claudia

Given that bar wouldn't be available there, and for the placeholder case, you can do |> # ?? doSomething(#), it seems like the need only arises in the form that has no placeholder?

ljharb avatar Nov 15 '19 17:11 ljharb

@ljharb That would execute doSomething only if # is nullish, which I think is the opposite of what's intended here.

mAAdhaTTah avatar Nov 15 '19 18:11 mAAdhaTTah

@mAAdhaTTah then # || doSomething(#) or # == null ? # : doSomething(#), either way no additional syntax support would be needed.

ljharb avatar Nov 15 '19 19:11 ljharb

Feasibly that works with F# as well, just needs to be wrapped in an arrow function.

Regardless though, I'm also inclined to think this should be a separate, follow-on proposal rather than overloading the initial operator design.

mAAdhaTTah avatar Nov 15 '19 19:11 mAAdhaTTah

This would really bloat the proposal and it can be done in a separate proposal after this one has already been promoted to stage3. Does seem useful though.

KristjanTammekivi avatar Nov 29 '19 09:11 KristjanTammekivi

Happy to support anyone who wants to make a new proposal for this. Typical real-world usage example:

Example usage:

function loadFromCrossSessionStorage(): SavedData {
  return (window.localStorage.getItem(DATA_KEY) ?> JSON.parse) ?? DEFAULT_DATA;
}

Compare with required code:

function loadFromCrossSessionStorage(): SavedData {
  const savedData = window.localStorage.getItem(DATA_KEY);
  if (savedData != null) {
    return JSON.parse(savedData);
  }
  return DEFAULT_DATA;
}

Compare with @ljharb's suggestion:

function loadFromCrossSessionStorage(): SavedData {
  return (window.localStorage.getItem(DATA_KEY) |> # != null ? JSON.parse(#) : #)
    ?? DEFAULT_DATA;
}

xixixao avatar Dec 16 '19 06:12 xixixao

Just my personal opinion, but I think this usage is getting just a bit too tricky. I worry that if we start adding many cute combinations like this, it will get hard to read JavaScript code.

littledan avatar Dec 16 '19 06:12 littledan

@littledan While I agree in general and I plan specifically not to suggest any further, I feel this one, optional pipeline chaining, to be generally and broadly useful enough to merit a single exception. This is about as far as I'd be okay with, and I agree anything more is just superfluous and unnecessary.

For comparison, here's it compared to a hypothetical String prototype method equivalent to JSON.parse:

function loadFromCrossSessionStorage() {
  return (window.localStorage.getItem(DATA_KEY) ?> JSON.parse) ?? DEFAULT_DATA;
}

function loadFromCrossSessionStorage() {
  return window.localStorage.getItem(DATA_KEY)?.readJSON() ?? DEFAULT_DATA;
}

Visually, it doesn't look that different, and semantically, the way a lot of people tend to use pipeline operators, it fits right into their mental model of them being "method-like".


Separately, for language precedent:

  • Most FP languages with this operator or equivalent (OCaml, Elm, PureScript, Haskell via f & x = f x, etc.) use explicit option types, and so they just use monadic bind or similar for similar effect. Instead of doing x |> f or use a special x ?> f operator, they'd do x |> map f, x |> Option.map f, or similar, and their compilers virtually always compile that indirection away. This is mostly because they don't have nulls at all, and so they have no need for an operator like this. Instead, they just use a wrapper type that allows optional values. It's a similar story with Result<T, E>/Maybe a b types where they use those instead of try/catch (and process those similarly).

    Here's @xixixao's example ported to OCaml + BuckleScript's Belt (a standard library optimized for compilation to JS):

    pen Belt
    / DOM interop code omitted
    et loadFromCrossSessionStorage () : SavedData.t =
    window |> Window.localStorage |> Storage.getItem DATA_KEY
    |> Option.flatMap (fn s -> Js.Json.parseExn s |> Js.Json.decodeObject)
    |> Option.flatMap SavedData.decode
    |> Option.getWithDefault DEFAULT_DATA
    
    Interop code

    BuckleScript doesn't come with DOM bindings and no reasonably stable library exists that provides them automatically, so we have to make our own.

    odule Storage : sig
    type t
    val getItem : t -> string option
    nd
    odule Window : sig
    type t
    val localStorage : t -> Storage.t
    nd
    xternal window : Window.t = "window" [@@bs.val]
    
    odule Storage = struct
    type t
    external _getItem : t -> string Js.Nullable.t = "getItem" [@@bs.method]
    let getItem t = Js.Nullable.toOption (_getItem t)
    nd
    odule Window = struct
    type t
    external _localStorage : t -> Storage.t = "localStorage" [@@bs.get]
    let localStorage = _localStorage
    nd
    
  • Swift, C#, and Kotlin all three support extension methods and a null coalescing operator, which works out to near identical effect to my proposal when used together.

    Here's @xixixao's example ported to Kotlin, using an inline extension method:

    mport kotlin.js.JSON
    mport kotlin.browser.window
    
    rivate inline fun String.readJSON() = JSON.parse(this).unsafeCast<SavedData>()
    un loadFromCrossSessionStorage(): SavedData {
    return window.localStorage.getItem(DATA_KEY)?.readJSON() ?? DEFAULT_DATA
    
    
  • Rust uses methods instead of pipeline operators, and so it's more idiomatic to use those when you'd ordinarily use a pipeline operator. For optional values, opt.map(f) is preferred, but there is the sugar foo.method()? for match foo.method() { Some(x) -> x, None -> return None }, which works similarly to foo?.method() ?? return nil for an extension method in C# or Kotlin.

    Here's @xixixao's example ported to Rust Nightly + stdweb, using a simple .and_then to efficiently chain:

    se stdweb::*;
    n load_from_cross_session_storage() -> SavedData {
    web::window().local_storage().get(DATA_KEY)
    	.and_then(|json| js!{ JSON.parse(@{json}) }.try_into())
    	.unwrap_or(DEFAULT_DATA)
    
    

    For stable Rust, change .try_into() to .into_reference()?.downcast(), what it's implemented in terms of.

  • Elixir appears to be an oddball that 1. features an explicit pipeline operator, 2. features a literal nil value, and 3. doesn't have anything to chain nulls with. It also features no way to return early from a function (or anything that could provide similar functionality), so that limitation is pretty consistent with the rest of the language.

    Here's a hypothetical translation of @xixixao's example to Elixir:*

    equire JSON
    ef load_from_cross_section_storage()
    json_string = Js.global("window")
    	|> Window.local_storage()
    	|> Storage.item(DATA_KEY)
    if json_string.is_null()
    	DEFAULT_DATA
    else
    	JSON.decode!(json_string)
    end
    nd
    

    * I'm intentionally avoiding ElixirScript, as that doesn't appear to have much traction and has nowhere close to the level of support that the official Elixir compiler has, in contrast to cargo-web + stdweb for Rust, BuckleScript for OCaml, or ClojureScript for Clojure.

  • As for a language that doesn't support it, let's use Java as an example. Here's @xixixao's example translated to Java + GWT:

    rivate static SavedDataFactory factory = GWT.create(SavedDataFactory.class);
    
    ublic SavedData loadFromCrossSectionStorage() {
    String json = Storage.getLocalStorageIfSupported().getItem(DATA_KEY);
    if (json == null) {
    	return DEFAULT_DATA;
    } else {
    	return AutoBeanCodex.decode(factory, SavedData.class, json).as();
    }
    
    

    If you have to repeat this logic in several places, you can imagine it'd get clumsy in a hurry.

So in summary, that simple operation is itself already pretty clumsy in languages that lack equivalent functionality, and it does in fact get harder to read IMHO the more often it appears. Personally, I find myself abstracting over this lack of functionality in JS a lot, and in a few extreme cases wrote a dedicated helper just for it.

dead-claudia avatar Dec 19 '19 03:12 dead-claudia

@isiahmeadows

This is really a helpful analysis!

Personally I don't like ?|> or ?> because we never need such operator in mainstream fp languages, but as your comment, it just another symptom of mismatching between mainstream fp and javascript world (no option types).

With the examples of other languages, I am increasingly convinced that we may need a separate Extension methods proposal to solve this problem.

hax avatar Jan 17 '20 07:01 hax

@hax

With the examples of other languages, I am increasingly convinced that we may need a separate Extension methods proposal to solve this problem.

Check out https://github.com/tc39/proposal-pipeline-operator/issues/55 - that covers the this-chaining style (which is technically mathematically equivalent).

dead-claudia avatar Jan 17 '20 16:01 dead-claudia

@isiahmeadows Thank you for the link.

Actually now there are 5 different strategies for a@b(...args):

  1. b.call(a, ..args)
  2. b(a, ...args)
  3. b(...args, a)
  4. b(...args)(a) (F# style)
  5. syntax error, require explicit placeholder like a@b(x, #, y) for b(x, a, y) (Smart pipeline style)

There are already many discussion between last two options (F# vs Smart style), so no need to repeat them here. What I what to say is, many use cases of first option can never be meet with option 4 and 5. Especially option 5 can cover all use cases of 2,3,4 but not 1.

I also think the main motivation of Extension methods is not method/function chaining, So I believe we can have both Extension methods and Pipeline op.

Return to this issue itself (optional chaining), it's strange to have special pipeline op for it, but I feel it's acceptable to have a?::b(1). And it also show us another important difference between Extension methods with Pipeline: Extension methods are more OO-style so programmers will also expect extension accessors (a?::x which match a?.x). From the Extension perspective (like Extension in Swift/Kotlin), it's actually strange to have a::b work as b.bind(a), I feel many would expect b as a getter/setter pair ({ get() {...}, set(v) {...} }) and a::b should work as b.get.call(a), a::b = v should work as b.set.call(a, v). I would like to create an Extension proposal follow this semantic.

hax avatar Jan 19 '20 02:01 hax

@hax I agree that (a?::b as an extension property accessor) could be useful, but I'd rather keep that to a separate follow-up proposal and not particular to this bug.

dead-claudia avatar Jan 22 '20 23:01 dead-claudia

@littledan I concur w/ @isiahmeadows this is definitely an exception & would be immensely useful for those who are most likely intend to write in this style, functional programmers or reactive functional programmers. It would save a tremendous amount of time having to normalize and wrap over methods like window.localStorage to do data process pipelining of various lengths of depth efficiently.

lozandier avatar Feb 06 '20 23:02 lozandier

I find using pipeline, I end up with tons of nullish checks. Lots of times, I really just want an Option, which just isn't that easy to work with in JS. Instead, we have null, and I think we should leverage that. To that end, I think if we build a proposal out of this, the operator should be ||>, a visual indicator that we're really talking about a second rail. In particularly long pipelines it should be very easy to parse null handling by the reappearance of |> after a series of ||>.

mikestopcontinues avatar Mar 21 '20 17:03 mikestopcontinues

It seems like support for optional chaining pipelining can be sort of cleanly separated from the rest of the pipeline proposal, and added as a follow-on. Is this accurate, or are there additional cross-cutting concerns?

littledan avatar May 01 '20 16:05 littledan

It can be a follow-on - it already depends on the pipeline operator making it.

dead-claudia avatar May 01 '20 20:05 dead-claudia

Has anyone taken to writing a proposal for this add-on?

I'm doing game dev and this would greatly clean up my code. I could write collision detection as such:

collidesHow(entityA, entityB) ?> onCollision) So if the entities collide, then onCollision is called with how, otherwise nothing is called.

Without this addition, I'd have to unpackage the how: collidesHow(entityA, entityB) ?> how => how && onCollision(how)

And without the pipeline proposal, I'm actually using map, then filter, then foreach to build up what a potential collision is, filtering it if it didn't collide, then calling onCollision if it did. It reads nicely, but is less effecient.

funkjunky avatar Jul 12 '20 03:07 funkjunky

This might be going too far, but what about another extension using the array syntax:

collidesHow(entityA, entityB) ?> [storeCollision, onCollision]

which would be equivilent to:

const how = collidesHow(entityA, entityB);
if (!!how) {
  storeCollision(how);
  onCollision(how);
}

Note: this is because pipeline works by chaining and if storeCollision doesn't return how, then we wouldn't be able to pipe it to onCollision.

funkjunky avatar Jul 12 '20 03:07 funkjunky

How about:

const ifNotNull = f => a => a && f(a)

collidesHow(entityA, entityB) |> ifNotNull(onCollision)

Not sure what it does to performance but since ifNotNull(onCollision) has to be resolved only once, and not every iteration, I think it couldn't hurt too much...

Also if you use ifNotNull a lot, you could come up with a shorter name so it doesn't add that many extra characters.

Jopie64 avatar Jul 12 '20 07:07 Jopie64

Point-free is more ideal; or is that a suggestion for today prior to a functioning shorthand like ?> that was suggested?

On Sun, Jul 12, 2020 at 12:39 AM Johan [email protected] wrote:

How about:

const ifNotNull = f => a => a && f(a) collidesHow(entityA, entityB) |> ifNotNull(onCollision)

Not sure what it does to performance but since ifNotNull(onCollision) has to be resolved only once, and not every iteration, I think it couldn't hurt too much...

Also if you use ifNotNull a lot, you could come up with a shorter name so it doesn't add that many extra characters.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/tc39/proposal-pipeline-operator/issues/159#issuecomment-657186942, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAJKUOFWJNO26D46OBDTRTTR3FSB3ANCNFSM4JN57JOQ .

-- Kevin Lozandier [email protected] [email protected]

lozandier avatar Jul 12 '20 13:07 lozandier

@Jopie64 That wouldn't short-circuit and end the pipeline on null, though, like (I'm assuming) the ?> operator would. So for longer pipelines, you might need to wrap every step of the pipeline after first possibly null return value

let newScore = person
  |> getScoreOrNull
  ?> double
  |> (_ => add(7, _))
  |> (_ => boundScore(0, 100, _));

// would become
let newScore = person
  |> getScoreOrNull
  |> ifNotNull(double)
  |> ifNotNull(_ => add(7, _))
  |> ifNotNull(_ => boundScore(0, 100, _));

// or nested pipelines
let newScore = person
  |> getScoreOrNull
  |> ifNotNull(_  => _
    |> double
    |> (_ => add(7, _))
    |> (_ => boundScore(0, 100, _))
  );

noppa avatar Jul 12 '20 18:07 noppa

True, I didn't think of short-circuiting... Still it could be a less ideal but ok workaround I think when the minimal proposal without optional chaining was accepted.

Jopie64 avatar Jul 12 '20 18:07 Jopie64

@noppa My proposed semantics would align with the optional chaining operator.

let newScore = person
  |> getScoreOrNull
  ?> double
  |> (_ => add(7, _))
  |> (_ => boundScore(0, 100, _))

// Would become
let newScore = person
  |> getScoreOrNull
  |> ifNotNull(_ => _
    |> double
    |> (_ => add(7, _))
    |> (_ => boundScore(0, 100, _))
  )

// Or as a statement
let score = getScoreOrNull(person)
let newScore = score != null ? boundScore(0, 100, add(7, double(score))) : undefined

dead-claudia avatar Jul 13 '20 23:07 dead-claudia

@isiahmeadows Yeah, makes sense.

To clarify, I didn't mean that my "alternative examples" were 100% equivalent to the ?> version or the result of transpiling pipelines. We were talking about how one might achieve "optional" pipelining without this ?> proposal if it didn't get in for the first pipeline version, and

getScoreOrNull
  |> ifNotNull(..)
  |> ifNotNull(..)

is something I could see myself using as a workaround instead of nested pipelines or breaking the pipeline to a statement, at least in some cases.

But it's definitely less than ideal workaround, a short-circuiting ?> operator would be much nicer to use.

noppa avatar Jul 14 '20 08:07 noppa

@funkjunky I didn't write a proposal for this, but I would love to include it here: https://xixixao.github.io/proposal-hack-pipelines/index.html#sec-additional-feature-op .

xixixao avatar Jul 15 '20 06:07 xixixao

Should null ?> (x => x) evaluate to undefined (like null?.() does) or null?

phaux avatar Jul 27 '20 10:07 phaux

I believe it should evaluate to undefined.

ExE-Boss avatar Jul 27 '20 13:07 ExE-Boss

@phaux The idea is that it'd carry the same propagation semantics, so it would return undefined in this case.

dead-claudia avatar Jul 27 '20 13:07 dead-claudia