Stream: ideas

Topic: Do we still need `|>`?


view this post on Zulip jan kili (Feb 12 2025 at 21:47):

I think so! At least until method-style function calls land in Roc, but possibly even after that. I've seen it mentioned several times that |> (also known as the pipe operator, or jokingly pizza :pizza:) can/should be removed from Roc with WSA, but I'm skeptical of that. Since it's one of Roc's most recognizable operators (and personally one of my favorite elements of its syntax), I'd like us to fully reconsider it (and its family of related operators like .) in the context of the syntax changes coming this year.

I think pipe shines best in multi-line pipelines. Here's an example I've used in previous topics...

main! = |_|
    "./input.txt"
    |> Path.from_str
    |> try Path.read_bytes!
    |> try Foo.from_bytes
    |> transform(2, Much)
    |> try Foo.to_bytes
    |> Path.write_bytes!(Path.from_str("./output.txt"))?

    Stdout.line!("🥳 See ./output.txt")

to ask about how we expect multi-line function call chains might look when method-style function calls land with static dispatch in several months after the compiler rewrite:

main! = |_|
    "./input.txt"
    .(Path.from_str)
    .read_bytes!?
    .(Foo.from_bytes)?
    .(transform)(2, Much)
    .to_bytes?
    .(Path.write_bytes!)(Path.from_str("./output.txt"))

    Stdout.line!("🥳 See ./output.txt")

It seems that .(local_fn) (also known as the "pass to" syntax) could be a one-to-one replacement for most pipes. However, I'm unclear whether folks want .(local_fn) to be that ubiquitous, and suspect that there are many circumstances where having a non-method-style function chaining syntax is desirable.

Does anyone have an example of PNC+SD code that feels better as PNC+SD+:pizza:?

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:00):

The main problem is precedence, how do you make |> work with method calls in a way that doesn't look weird?

data = start.call() |> local_func.next_method()

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:01):

You'd have to make a space-based operator bind the same as dots

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:02):

Also, the current plan is for .method().other() to act as sugar for |x| x.method().other() (from an old example)

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:03):

I will agree that from an Elm/FP-in-general background, I much prefer the reading of |> over the .(local_func)(args) thing

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:05):

With the recent whitespace discussions, we could make |> always add newlines to the whole chain, turning the above to

data =
    start.call()
    |> local_func
    .next_method()

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:10):

Which I believe is less confusing

view this post on Zulip Richard Feldman (Feb 12 2025 at 22:36):

I realize I may be in the minority on this, but I really like being able to do:

time = 4.(hours).(ago!)

and I like that a lot better than

time = 4 |> hours |> ago!

view this post on Zulip Richard Feldman (Feb 12 2025 at 22:37):

I'm not a big fan of .(fn)(arg) but I don't have a great alternative idea either

view this post on Zulip Richard Feldman (Feb 12 2025 at 22:38):

I do like . being the consistent chaining/autocomplete character, and I do like the precedence being more straightforward and easy to understand

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:41):

If it weren't even more parens, I'd like it more

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:42):

But the alternatives we discussed weren't great either

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:42):

I'm considering throughout these discussions that most function chains should be methods anyway

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:43):

So all these examples with |> or .(func) are good at showing how these would look when used in volume

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:43):

But they inflate the perceived frequency of these tools

view this post on Zulip Richard Feldman (Feb 12 2025 at 22:43):

to be fair though, I'm also not a fan of multi-arg PNC with pipe:

|> fn(arg1, arg2)

that super doesn't look like a function that is being passed 3 arguments :sweat_smile:

view this post on Zulip Richard Feldman (Feb 12 2025 at 22:43):

and yet it is

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:44):

I'd say val.method(arg1, arg2) is the same thing, we're just used to it

view this post on Zulip Sam Mohr (Feb 12 2025 at 22:44):

But we're used to it so...

view this post on Zulip Richard Feldman (Feb 12 2025 at 22:46):

fair!

view this post on Zulip Richard Feldman (Feb 12 2025 at 22:47):

but yes, I am used to it and it doesn't look weird to me :laughing:

view this post on Zulip Brendan Hansknecht (Feb 13 2025 at 00:07):

Richard Feldman said:

I realize I may be in the minority on this, but I really like being able to do:

time = 4.(hours).(ago!)

and I like that a lot better than

time = 4 |> hours |> ago!

I totally agree. I really like the .() syntax in general for local calls

view this post on Zulip Sky Rose (Feb 13 2025 at 04:33):

I'm not a big fan of .(fn)(arg) but I don't have a great alternative idea either

I remember .$ and .. came up as options a couple times. arg1..fn(arg2). Were they ever ruled out?

view this post on Zulip jan kili (Feb 13 2025 at 04:35):

.$ is getting some re-evaluation in #ideas > static dispatch - pass_to alternative , along with discussion of all alternatives for the method-style syntax version of |>

view this post on Zulip jan kili (Feb 18 2025 at 19:02):

This seems like consensus to fully remove |> by not implementing it in the new compiler.

view this post on Zulip jan kili (Feb 18 2025 at 19:04):

For deprecation, I propose matching whatever is decided in #contributing > deprecating WSA (whitespace application for function calls) but without any pre-v0.1.0 PRs because the replacement syntax won't work before then.

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:08):

so, revisiting this thread (and the related #ideas > static dispatch - pass_to alternative) now that the new compiler has been in use for awhile, I actually think arg1.(fn)(arg2, arg3) would be a better syntax choice than -> and I'd like to revisit that choice

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:09):

my main motivation for revisiting this syntax choice is that I found a different use case that I think a->b() is a more natural fit for

view this post on Zulip Karl (Jul 30 2026 at 19:12):

The only specific use case I've run across in the new compiler is { ...}.Foo-> Foo.map(...) because you can't chain off the map2 builder syntax.

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:15):

oh we should just allow that

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:15):

or wait, does this work? ({ ... }.Foo).map(...)

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:15):

I guess it looks kinda weird

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:16):

but { ... }.Foo.map(...) seems fine to me in the same way that like 123.I64.is_negative() seems fine (although that specific example is kinda silly)

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:16):

so let's assume that was allowed

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:17):

a recent example by @Aurélien Geron would go from this:

get_iso_str : List(U8) -> Try(Str, _)
get_iso_str = |bytes| {
        str = bytes->Str.from_utf8()?
        response : { local_time : Str }
        response = Json.parse(str)?
        Ok(response.local_time)
}

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:18):

...to this:

get_iso_str : List(U8) -> Try(Str, _)
get_iso_str = |bytes| {
        str = bytes.(Str.from_utf8)()?
        response : { local_time : Str }
        response = Json.parse(str)?
        Ok(response.local_time)
}

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:18):

I think a relevant factor here is that this operation comes up waaaaaay less often than it did before we had static dispatch :smile:

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:21):

and I also like the visual similarity with calling a function from a record field:

view this post on Zulip Karl (Jul 30 2026 at 19:32):

For what it's worth, I find it unintuitive in the same way I find stack machine invocation unintuitive. I get that method invocation is basically the same thing but I think of a method as "belonging" to the namespace/object in a way while the fn here is completely unrelated.

If the motivation is to free up -> then what's wrong with |>?

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:41):

originally the concern there was that it doesn't chain well, e.g. you can do foo.(bar)().baz() but not foo |> bar .baz()

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:42):

an example from that other thread was:

"./input.txt"
    .(Path.from_str)()
    .read_bytes!()?
    .(Foo.from_bytes)()?
    .(transform)(2, Much)
    .to_bytes()?
    .(Path.write_bytes!)(Path.from_str("./output.txt"))

view this post on Zulip Richard Feldman (Jul 30 2026 at 19:43):

so those lines that start with .( couldn't start with |> because you'd need to wrap everything in increasing levels of nested parens

view this post on Zulip Bryce Miller (Jul 30 2026 at 20:30):

For what it's worth, Given:

# Option 1
str = Str.from_utf8(bytes)?

# Option 2
str = bytes->Str.from_utf8()?

#Option 3
str = bytes.(Str.from_utf8)()?

I'd default to just writing option 1. I'd be ok with option 2, as I can still grok it at a glance. I have a pretty strong negative reaction to option 3, to the point that I would seriously consider asking someone to change it to option 1 in a code review.

But I realize this isn't really the use case this syntax is intended to facilitate :sweat_smile:

view this post on Zulip Bryce Miller (Jul 30 2026 at 20:33):

str = bytes.pass_to(Str.from_utf8)?

I actually like this better than the sugar, though I don't remember if this was actually the way this worked (or was proposed to work) previously.

view this post on Zulip Bryce Miller (Jul 30 2026 at 20:42):

Here are some examples from roc-pg:

# Before
    result_seq_str = result_seq
        .map(|num| num.to_str())
        ->Str.join_with(", ")

# After
    result_seq_str = result_seq
        .map(|num| num.to_str())
        .(Str.join_with)(", ")

view this post on Zulip Bryce Miller (Jul 30 2026 at 20:50):

# Before
        startup = |{ user, database }| Encode.sequence([
            # Version number
            Encode.i16(3),
            Encode.i16(0),
            # Encoding
            Encode.sequence([
                encode_param("client_encoding", "utf_8"),
                encode_param("user", user),
                encode_param("database", database),
            ])->Encode.null_terminate(),
        ])->prepend_length()

# After
        startup = |{ user, database }| Encode.sequence([
            # Version number
            Encode.i16(3),
            Encode.i16(0),
            # Encoding
            Encode.sequence([
                encode_param("client_encoding", "utf_8"),
                encode_param("user", user),
                encode_param("database", database),
            ]).(Encode.null_terminate)(),
        ]).(prepend_length)()

# Alt before
startup : { user : Str, database : Str } -> List(U8)
        startup = |{ user, database }| [
            # Version number
            Encode.i16(3),
            Encode.i16(0),
            # Encoding
            [
                encode_param("client_encoding", "utf_8"),
                encode_param("user", user),
                encode_param("database", database),
            ]
                ->Encode.sequence()
                ->Encode.null_terminate(),
        ]
            ->Encode.sequence()
            ->prepend_length()
# Alt After
startup : { user : Str, database : Str } -> List(U8)
        startup = |{ user, database }| [
            # Version number
            Encode.i16(3),
            Encode.i16(0),
            # Encoding
            [
                encode_param("client_encoding", "utf_8"),
                encode_param("user", user),
                encode_param("database", database),
            ]
                .(Encode.sequence)()
                .(Encode.null_terminate)(),
        ]
            .(Encode.sequence)()
            .(prepend_length)()

view this post on Zulip Bryce Miller (Jul 30 2026 at 20:56):

# Before
error_response : Decode(Backend.Message, _)
error_response = known_str_fields.await(
    |dict| 'S'->required_field(
        dict,
        |localized_severity| 'V'->optional_field_with(
            dict,
            decode_severity,
            |severity| 'C'->required_field(
                dict,
                |code| 'M'->required_field(
                    dict,
                    |msg| 'P'->optional_field_with(
                        dict,
                        U32.from_str,
                        |position| 'p'->optional_field_with(
                            dict,
                            U32.from_str,
                            |internal_position| ErrorResponse({
                                localized_severity,
                                severity,
                                code,
                                message: msg,
                                detail: 'D'->optional_field(dict),
                                hint: 'H'->optional_field(dict),
                                position,
                                internal_position,
                                internal_query: 'q'->optional_field(dict),
                                ewhere: 'W'->optional_field(dict),
                                schema_name: 's'->optional_field(dict),
                                table_name: 't'->optional_field(dict),
                                column_name: 'c'->optional_field(dict),
                                data_type_name: 'd'->optional_field(dict),
                                constraint_name: 'n'->optional_field(dict),
                                file: 'F'->optional_field(dict),
                                line: 'L'->optional_field(dict),
                                routine: 'R'->optional_field(dict),
                            })->Decode.succeed(),
                        ),
                    ),
                ),
            ),
        ),
    ),
)

# After
error_response : Decode(Backend.Message, _)
error_response = known_str_fields.await(
    |dict| 'S'.(required_field)(
        dict,
        |localized_severity| 'V'.(optional_field_with)(
            dict,
            decode_severity,
            |severity| 'C'.(required_field)(
                dict,
                |code| 'M'.(required_field)(
                    dict,
                    |msg| 'P'.(optional_field_with)(
                        dict,
                        U32.from_str,
                        |position| 'p'.(optional_field_with)(
                            dict,
                            U32.from_str,
                            |internal_position| ErrorResponse({
                                localized_severity,
                                severity,
                                code,
                                message: msg,
                                detail: 'D'.(optional_field)(dict),
                                hint: 'H'.(optional_field)(dict),
                                position,
                                internal_position,
                                internal_query: 'q'.(optional_field)(dict),
                                ewhere: 'W'.(optional_field)(dict),
                                schema_name: 's'.(optional_field)(dict),
                                table_name: 't'.(optional_field)(dict),
                                column_name: 'c'.(optional_field)(dict),
                                data_type_name: 'd'.(optional_field)(dict),
                                constraint_name: 'n'.(optional_field)(dict),
                                file: 'F'.(optional_field)(dict),
                                line: 'L'.(optional_field)(dict),
                                routine: 'R'.(optional_field)(dict),
                            }).(Decode.succeed)(),
                        ),
                    ),
                ),
            ),
        ),
    ),
)

view this post on Zulip Bryce Miller (Jul 30 2026 at 20:58):

Sorry for the spam! I just realized I had lots of real-world examples in case it's helpful.

view this post on Zulip Bryce Miller (Jul 30 2026 at 21:04):

(Aside, is the last example a candidate for record builder syntax instead? It's just a little awkward regardless of pipe syntax or whether we use the pipe.)

view this post on Zulip Aurélien Geron (Jul 30 2026 at 21:09):

I also have a strong negative reaction against option 3 (bytes.(Str.from_utf8)). After using it for a while, I actually quite like option 2. It might help to know what other usage you have in mind for -> @Richard Feldman ? Couldn't you use |> for that other use case?
Btw, what's wrong with bytes |> Str.from_utf8()? Why can't it work exactly like ->, with the exact same operator precedence?

view this post on Zulip Bryce Miller (Jul 30 2026 at 21:11):

I was actually just wondering why -> works but |> wouldn't

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:12):

I think it works fine multi-line but it looks very strange single-line

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:13):

compare:

x = foo->bar().baz()

x = foo|>bar().baz()

x = foo |> bar().baz()

x = foo |> bar() .baz()

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:13):

to me, only the first of those looks natural

view this post on Zulip Aurélien Geron (Jul 30 2026 at 21:13):

I see your point.

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:15):

without going on a huge tangent (which may not be avoidable :sweat_smile:) the other use case for -> is for simulating effects in tests. It's important to be able to write tests where the whole test is a pure function (so roc test can deterministically cache its outputs and not re-run tests unnecessarily), but a very important part of the test is transitioning state as you progress through the test

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:16):

it has something in common with random number generation, where you say "give me a seed, and I'll give you back the value you want and a new seed" and both of those could benefit from syntax sugar

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:18):

so originally I thought of $arg1<-fn(arg2, arg3) as sugar for

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:18):

so, today:

var $seed = initial_seed

# get random rgb
(r, $seed) = $seed.u8()
(g, $seed) = $seed.u8()
(b, $seed) = $seed.u8()

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:19):

with <-

var $seed = initial_seed

# get random rgb
r = $seed<-u8()
g = $seed<-u8()
b = $seed<-u8()

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:19):

or even:

(r, g, b) = ($seed<-u8(), $seed<-u8(), $seed<-u8())

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:21):

without opening the can of worms of the whole design for simulated effects, it involves heavy use of things like this:

req = $expected<-http_get(|_| Ok(simulated_response))?

expect req.url == expected_url

(path, data) = $expected<-fs_write(|_| Ok({}))?

view this post on Zulip Aurélien Geron (Jul 30 2026 at 21:23):

I like the <- in this situation. Why do you need ->?

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:26):

because with -> it pretty much does exactly what you'd expect from imperative languages which have -> and use it in this way - that is, "mutate" $expected

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:27):

compare:

(r, g, b) = ($seed<-u8(), $seed<-u8(), $seed<-u8())
req = $expected<-http_get(|_| Ok(simulated_response))?

expect req.url == expected_url

(path, data) = $expected<-fs_write(|_| Ok({}))?

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:27):

to:

(r, g, b) = ($seed->u8(), $seed->u8(), $seed->u8())
req = $expected->http_get(|_| Ok(simulated_response))?

expect req.url == expected_url

(path, data) = $expected->fs_write(|_| Ok({}))?

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:27):

if someone coming from a C, C++, or PHP background saw the latter I think they'd be unsurprised that the value of $expected is changing in there, because mutation in those languages is common

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:28):

and also -> is used for static dispatch of methods in these situations, and that's how it would be used here too

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:30):

put another way, I think if we'd already decided on -> for this reassignment use case, it's such a natural fit that it would be really hard to argue that we should use it for something else :sweat_smile:

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:31):

and the only reason we would is that, as it happens, we already chose that operator for something that imo it's not as natural a fit for (since everyone else uses |> for it and only ReScript uses -> for it)

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:32):

ooh wait, I just had an idea

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:32):

Aurélien Geron said:

what's wrong with bytes |> Str.from_utf8()? Why can't it work exactly like ->, with the exact same operator precedence?

what if we did this, and then stylistically we just had the formatter use parens in the single-line case so it doesn't look weird?

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:32):

because I think that case is the least common by far

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:34):

so:

"./input.txt"
    |> Path.from_str()
    .read_bytes!()?
    |> Foo.from_bytes()?
    |> transform(2, Much)
    .to_bytes()?
    |> Path.write_bytes!(Path.from_str("./output.txt"))
get_iso_str : List(U8) -> Try(Str, _)
get_iso_str = |bytes| {
        str = bytes |> Str.from_utf8()?
        response : { local_time : Str }
        response = Json.parse(str)?
        Ok(response.local_time)
}
x = (foo |> bar()).baz()

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:34):

so here the last one looks the least nice but you could always split it to be multiline if you wanted to

view this post on Zulip Aurélien Geron (Jul 30 2026 at 21:36):

Oh I really like that! :+1:

view this post on Zulip Aurélien Geron (Jul 30 2026 at 21:38):

The only caveat is that I would expect x = foo |> bar().baz() to mean x = foo |> (bar().baz()). It might be surprising to users. That said, the compiler could issue a warning.

view this post on Zulip Aurélien Geron (Jul 30 2026 at 21:39):

I mean a warning if you chain |> and . without parentheses on a single line.

view this post on Zulip Richard Feldman (Jul 30 2026 at 21:47):

Aurélien Geron said:

The only caveat is that I would expect x = foo |> bar().baz() to mean x = foo |> (bar().baz()). It might be surprising to users. That said, the compiler could issue a warning.

I'd just have the formatter rewrite it to use parens

view this post on Zulip Bryce Miller (Jul 30 2026 at 22:10):

I really like this idea too!

view this post on Zulip Bryce Miller (Jul 30 2026 at 22:15):

I expect that the single-line parens will bother me almost 0.

view this post on Zulip Bryce Miller (Jul 30 2026 at 22:19):

:folding_hand_fan:<- me

view this post on Zulip Aurélien Geron (Jul 30 2026 at 22:23):

I just asked antigravity to analyze the roc-isodate package to find all the places where -> and . are used on the same line, and to summarize the patterns found. Of course this is only my programming style, your mileage may vary:

  1. expect assertions with pipeline method calls. 90 in Tests.roc, for example:
    expect Date.from_iso_str("2024-01-23")?->Date.to_nanos_since_epoch() == ...
    expect Time.from_iso_str("11:11")?->Time.to_nanos_since_midnight() == ...
    expect !("🔥".to_utf8()->Utils.validate_utf8_single_bytes())
  1. Method chaining with record field access & string helpers (Utils.roc, Duration.roc):
    num_str = trim_to_last_sig_fig(nanos).drop_prefix("-")->pad_left_ascii('0', length)
    untrimmed_str.to_utf8().take_first(length + 1)->Str.from_utf8_lossy()
  1. Piping to Ok() / constructor functions (Time.roc, Date.roc, Duration.roc):
    Date.from_ymd(year, 1, 1)->Ok()
    Time.from_hms(hour, minute, 0)->Ok

view this post on Zulip jan kili (Jul 30 2026 at 22:23):

Me, a yearlong-absent Roc lover, watching one of my favorite Roc threads get necromanced with allusions to cool new post-compiler-rewrite syntaxes and my favorite-but-doomed operator getting a second chance:
:popcorn::face_with_open_eyes_and_hand_over_mouth:🤞🏻:pizza:

view this post on Zulip Aurélien Geron (Jul 30 2026 at 22:28):

Here's what my examples would look like with the new style:

1.
    expect (Date.from_iso_str("2024-01-23")? |> Date.to_nanos_since_epoch()) == ...
    expect (Time.from_iso_str("11:11")? |>Time.to_nanos_since_midnight()) == ...
    expect !("🔥".to_utf8() |> Utils.validate_utf8_single_bytes())

2.
    num_str = trim_to_last_sig_fig(nanos).drop_prefix("-") |> pad_left_ascii('0', length)
    untrimmed_str.to_utf8().take_first(length + 1) |> Str.from_utf8_lossy()

3.
    Date.from_ymd(year, 1, 1) |> Ok()
    Time.from_hms(hour, minute, 0) |> Ok

No big change, and I do prefer |> over ->. :+1:

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:28):

that last code example :wait_one_second: makes me wonder if we should allow omitting the parens if you don't have any further arguments to apply

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:28):

e.g. allow |> Str.from_utf8_lossy

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:29):

seems unambiguous and reasonable, and we could have the formatter drop the parens if they're there

view this post on Zulip jan kili (Jul 30 2026 at 22:32):

Is a chain of many short functions a/the pathological case?
(((((a |> b) |> c) |> d) |> e) |> f) |> g

view this post on Zulip Aurélien Geron (Jul 30 2026 at 22:33):

IMHO, the parentheses are only really needed when there's a mix of |> and .

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:35):

oh, here's maybe an easier rule: make the following be different

|> foo.bar()
|> foo .bar()

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:35):

so the first one is saying arg |> (foo.bar())

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:36):

and the second one is saying (arg |> foo).bar()

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:36):

and the space could also be a newline

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:36):

since that's the behavior you want in multiline

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:37):

and then there's no need for the formatter to insert parens because if you wanted that chaining the parens would be the only way to achieve it

view this post on Zulip Aurélien Geron (Jul 30 2026 at 22:37):

Mmmh, in general I'm not a fan of significant white space (much like the difference between f()? and f() ? ....
I think that the only ambiguity is when a |> is followed by a ., so we could add parentheses only in that case. For example a.b->c.d->e.f->g would become ((a.b |> c).d |> e).f |> g

view this post on Zulip Aurélien Geron (Jul 30 2026 at 22:41):

I just scanned through the 168 lines that use a mix of -> and . in roc-isodate, and there are barely any cases where parentheses would need to be added.

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:45):

it's fair about not liking significant whitespace, but if I know what foo |> bar does and I see (or write) the rare foo |> bar.baz() for the first time, I think I'd look at that and be pretty confident that it meant foo |> (bar.baz())

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:46):

in practice probably people will use parens regardless, but in general if someone is going to be surprised about how something works, I think it's worse if they are surprised after having been confident it worked a different way

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:48):

(also we use significant whitespace in several fundamental places, e.g. foo (bar, baz) is different from foo(bar, baz))

view this post on Zulip Aurélien Geron (Jul 30 2026 at 22:50):

Yeah, I guess you're right. Since the problem actually rarely occurs in practice (judging from the roc-isodate code), I'm fine with this. Btw, what does foo (bar, baz) mean?

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:55):

it's a standalone statement, same as if you put a newline between them

view this post on Zulip Aurélien Geron (Jul 30 2026 at 22:55):

Oooh, got it

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:56):

a more obvious example is foo!()(a, b)

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:57):

without significant whitespace, that would necessarily mean the same thing as:

foo!()

(a, b)

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:58):

which most languages solve by introducing semicolons :sweat_smile:

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:59):

for that specific case it could also work to require (foo!())(a, b) to work with partially-applied functions

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:59):

but significant whitespace feels nicer than that to me

view this post on Zulip Richard Feldman (Jul 30 2026 at 22:59):

the type of whitespace doesn't matter though; just presence or absence

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:00):

e.g. we don't care about indentation, tabs vs spaces vs newlines, etc.

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:05):

also technically line comments make newlines significant but everyone gives those a free pass :laughing:

view this post on Zulip Aurélien Geron (Jul 30 2026 at 23:07):

Oh wow, I just evaluated a = 1 + 2 b = a + 3 in the REPL and indeed I got a == 3 and b == 6. Tbh, I think I prefer a = 1 + 2; b = a + 3. I don't think I would ever use statement1 statement2.

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:09):

I thought about making semicolons count as whitespace but honestly I prefer just not having them in the language

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:09):

newlines are fine!

view this post on Zulip Aurélien Geron (Jul 30 2026 at 23:14):

Not having semi-colons is fine, newlines are great. I would just remove statement1 statement2: I don't see how chaining statements separated by spaces or tabs would ever be useful, and people might use this syntax by mistake if they type a space.

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:15):

we could, although to do that we'd have to teach the parser that newlines are significant :sweat_smile:

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:16):

not sure it's worth it

view this post on Zulip jan kili (Jul 30 2026 at 23:16):

Sounds like the formatter can/does replace that space with a newline?

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:16):

yeah

view this post on Zulip Aurélien Geron (Jul 30 2026 at 23:18):

One scenario where having multiple statements on the same line is helpful is when you want to run some code from a shell, for example python -c 'a=1+2; print(a)'. It's easier than using <<EOF for multiline stuff, and it works across platforms.

view this post on Zulip Jonathan (Jul 30 2026 at 23:36):

There's something about this combination of whitespace sensitivity and precedence that makes reading and grokking this kind of style quite difficult for me, especially if parens are optional for 1-ary functions. Granted it's much simpler than that, but I was really growing to quite like -> and felt it a better suit for the language now it works primarily through method chaining. Are there not other possibilities like : in a.map(f):g() or :> or .> perhaps?

Anyway, I appreciate that the data says these problems are fairly rare in practice, but it makes me a little squeamish to discard -> which seemed to be working so well! I'll probably get over it :smile:

view this post on Zulip Jonathan (Jul 30 2026 at 23:38):

Richard Feldman said:

it's fair about not liking significant whitespace, but if I know what foo |> bar does and I see (or write) the rare foo |> bar.baz() for the first time, I think I'd look at that and be pretty confident that it meant foo |> (bar.baz())

Side note - couldn't this be remedied by always requiring parens on the function being piped to? Ie foo |> bar would not be legal syntax.

view this post on Zulip Luke Boswell (Jul 30 2026 at 23:38):

Isn't it a goal to avoid significant whitespace though? it seems this discussion is arriving at a point where maybe that is what we want... but it's mitigated by being in very rare circumstances and the formatted breaks things apart so it is very obvious what is happening

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:45):

to be clear, the idea is not to discard ->, but rather to change its meaning and then find a replacement syntax for the current meaning :smile:

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:48):

Luke Boswell said:

Isn't it a goal to avoid significant whitespace though? it seems this discussion is arriving at a point where maybe that is what we want... but it's mitigated by being in very rare circumstances and the formatted breaks things apart so it is very obvious what is happening

I'd say the goal is to avoid the things people dislike about significant whitespace.

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:50):

and actually come to think of it, it's not even new :thinking:

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:50):

foo.bar already does something different from foo .bar

view this post on Zulip Richard Feldman (Jul 30 2026 at 23:51):

so really it's just declining to special-case it for |>

view this post on Zulip Luke Boswell (Jul 30 2026 at 23:51):

Richard Feldman said:

foo.bar already does something different from foo .bar

I thought that was a bug though... like we introduced parens and commas so things like this would always have one meaning

view this post on Zulip Luke Boswell (Jul 30 2026 at 23:52):

I don't feel strongly about any of this... I just feel a little suspicious of wss

view this post on Zulip Richard Feldman (Jul 31 2026 at 01:59):

as a quick note, since we don't support the thing that motivates the new semantics for -> I think we can introduce |> as a nonbreaking change for now, and have the formatter rewrite all uses of -> to |>, and then later we can introduce the new meaning of -> hopefully after ~all existing uses of -> have naturally been migrated over by the formatter

view this post on Zulip Luke Boswell (Jul 31 2026 at 02:00):

I like a plan :grinning_face_with_smiling_eyes:

view this post on Zulip jan kili (Jul 31 2026 at 02:57):

:nerd: Pinch me, I must be dreaming. :pizza:

view this post on Zulip Arya Elfren (Jul 31 2026 at 10:43):

Bryce Miller said:

Sorry for the spam! I just realized I had lots of real-world examples in case it's helpful.

You can use spoiler blocks if there's ever a concern about too many examples / codeblocks / stacktraces btw. If they're long zulip already does the "show more" but it's still a useful feature to know about. e.g.

Example: error_response

Example: startup

view this post on Zulip Bryce Miller (Jul 31 2026 at 11:33):

Oh, that actually does seem quite useful. Thanks!

view this post on Zulip Richard Feldman (Jul 31 2026 at 14:20):

landed on main!

view this post on Zulip Niclas Ahden (Jul 31 2026 at 14:39):

I go away for three days and return to |> making a comeback... you guys! :in_love:

view this post on Zulip Richard Feldman (Aug 07 2026 at 16:39):

@Aurélien Geron you mentioned somewhere (I thought it was in this thread but I can't find it now) that a |> foo(b)? should parse as (a |> foo(b))? - I've come around to this being the right design after all.

It still looks surprising to me, but I think the case where this will most often come up is in a big vertical pipeline, and having it work the other way would just destroy that use case, and I think that outweighs the surprise factor.

view this post on Zulip Richard Feldman (Aug 07 2026 at 16:59):

(this is already how it works on main now, just wanted to document my reasoning for future reference)

view this post on Zulip Krzysztof Skowronek (Aug 12 2026 at 08:28):

I know I'm late here, but it seems like the question is about syntax for going from |> back method chaining

What about |>.? So

someJson |> JSON.parse |>.name.substring(5)

Also, in F# there is "shorthand lambda" syntax, basically _.name is the same as fun x -> x.name, which let's you do array |> Seq.map _.name - maybe there is something there?

So something like

someJson |> JSON.parse |> _.name.substring(5)

view this post on Zulip MagicMan (Aug 12 2026 at 10:04):

This syntax is also in Lean 4: https://leanprover.github.io/functional_programming_in_lean/monad-transformers/conveniences.html#pipe-operators


Last updated: Aug 12 2026 at 12:35 UTC