Optional chaining support for the pipeline operator?
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
I think that this should be a separate proposal, with a different token (e.g. ?>)
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.
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 That would execute doSomething only if # is nullish, which I think is the opposite of what's intended here.
@mAAdhaTTah then # || doSomething(#) or # == null ? # : doSomething(#), either way no additional syntax support would be needed.
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.
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.
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;
}
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 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 doingx |> for use a specialx ?> foperator, they'd dox |> 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 withResult<T, E>/Maybe a btypes where they use those instead oftry/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_DATAInterop 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 sugarfoo.method()?formatch foo.method() { Some(x) -> x, None -> return None }, which works similarly tofoo?.method() ?? return nilfor an extension method in C# or Kotlin.Here's @xixixao's example ported to Rust Nightly + stdweb, using a simple
.and_thento 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
nilvalue, 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.
@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
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).
@isiahmeadows Thank you for the link.
Actually now there are 5 different strategies for a@b(...args):
b.call(a, ..args)b(a, ...args)b(...args, a)b(...args)(a)(F# style)- syntax error, require explicit placeholder like
a@b(x, #, y)forb(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 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.
@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.
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 ||>.
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?
It can be a follow-on - it already depends on the pipeline operator making it.
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.
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.
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.
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]
@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, _))
);
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.
@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
@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.
@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 .
Should null ?> (x => x) evaluate to undefined (like null?.() does) or null?
I believe it should evaluate to undefined.
@phaux The idea is that it'd carry the same propagation semantics, so it would return undefined in this case.