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:?
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()
You'd have to make a space-based operator bind the same as dots
Also, the current plan is for (from an old example).method().other() to act as sugar for |x| x.method().other()
I will agree that from an Elm/FP-in-general background, I much prefer the reading of |> over the .(local_func)(args) thing
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()
Which I believe is less confusing
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'm not a big fan of .(fn)(arg) but I don't have a great alternative idea either
I do like . being the consistent chaining/autocomplete character, and I do like the precedence being more straightforward and easy to understand
If it weren't even more parens, I'd like it more
But the alternatives we discussed weren't great either
I'm considering throughout these discussions that most function chains should be methods anyway
So all these examples with |> or .(func) are good at showing how these would look when used in volume
But they inflate the perceived frequency of these tools
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:
and yet it is
I'd say val.method(arg1, arg2) is the same thing, we're just used to it
But we're used to it so...
fair!
but yes, I am used to it and it doesn't look weird to me :laughing:
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
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?
.$ 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 |>
This seems like consensus to fully remove |> by not implementing it in the new compiler.
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.
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
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
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.
oh we should just allow that
or wait, does this work? ({ ... }.Foo).map(...)
I guess it looks kinda weird
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)
so let's assume that was allowed
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)
}
...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)
}
I think a relevant factor here is that this operation comes up waaaaaay less often than it did before we had static dispatch :smile:
and I also like the visual similarity with calling a function from a record field:
arg1.(fn)(arg2, arg3)(rec1.fn)(arg1, arg2, arg3)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 |>?
originally the concern there was that it doesn't chain well, e.g. you can do foo.(bar)().baz() but not foo |> bar .baz()
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"))
so those lines that start with .( couldn't start with |> because you'd need to wrap everything in increasing levels of nested parens
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:
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.
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)(", ")
# 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)()
# 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)(),
),
),
),
),
),
),
)
Sorry for the spam! I just realized I had lots of real-world examples in case it's helpful.
(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.)
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?
I was actually just wondering why -> works but |> wouldn't
I think it works fine multi-line but it looks very strange single-line
compare:
x = foo->bar().baz()
x = foo|>bar().baz()
x = foo |> bar().baz()
x = foo |> bar() .baz()
to me, only the first of those looks natural
I see your point.
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
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
so originally I thought of $arg1<-fn(arg2, arg3) as sugar for
fn passing $arg1 and arg2 and arg3fn returns a tuple with exactly 2 values, one of which has the same type as $arg1$arg1 gets reassigned to that valueso, today:
var $seed = initial_seed
# get random rgb
(r, $seed) = $seed.u8()
(g, $seed) = $seed.u8()
(b, $seed) = $seed.u8()
with <-
var $seed = initial_seed
# get random rgb
r = $seed<-u8()
g = $seed<-u8()
b = $seed<-u8()
or even:
(r, g, b) = ($seed<-u8(), $seed<-u8(), $seed<-u8())
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({}))?
I like the <- in this situation. Why do you need ->?
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
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({}))?
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({}))?
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
and also -> is used for static dispatch of methods in these situations, and that's how it would be used here too
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:
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)
ooh wait, I just had an idea
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?
because I think that case is the least common by far
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()
so here the last one looks the least nice but you could always split it to be multiline if you wanted to
Oh I really like that! :+1:
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 mean a warning if you chain |> and . without parentheses on a single line.
Aurélien Geron said:
The only caveat is that I would expect
x = foo |> bar().baz()to meanx = 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
I really like this idea too!
I expect that the single-line parens will bother me almost 0.
:folding_hand_fan:<- me
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:
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())
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()
Ok() / constructor functions (Time.roc, Date.roc, Duration.roc): Date.from_ymd(year, 1, 1)->Ok()
Time.from_hms(hour, minute, 0)->Ok
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:
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:
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
e.g. allow |> Str.from_utf8_lossy
seems unambiguous and reasonable, and we could have the formatter drop the parens if they're there
Is a chain of many short functions a/the pathological case?
(((((a |> b) |> c) |> d) |> e) |> f) |> g
IMHO, the parentheses are only really needed when there's a mix of |> and .
oh, here's maybe an easier rule: make the following be different
|> foo.bar()
|> foo .bar()
so the first one is saying arg |> (foo.bar())
and the second one is saying (arg |> foo).bar()
and the space could also be a newline
since that's the behavior you want in multiline
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
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
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.
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())
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
(also we use significant whitespace in several fundamental places, e.g. foo (bar, baz) is different from foo(bar, baz))
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?
it's a standalone statement, same as if you put a newline between them
Oooh, got it
a more obvious example is foo!()(a, b)
without significant whitespace, that would necessarily mean the same thing as:
foo!()
(a, b)
which most languages solve by introducing semicolons :sweat_smile:
for that specific case it could also work to require (foo!())(a, b) to work with partially-applied functions
but significant whitespace feels nicer than that to me
the type of whitespace doesn't matter though; just presence or absence
e.g. we don't care about indentation, tabs vs spaces vs newlines, etc.
also technically line comments make newlines significant but everyone gives those a free pass :laughing:
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.
I thought about making semicolons count as whitespace but honestly I prefer just not having them in the language
newlines are fine!
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.
we could, although to do that we'd have to teach the parser that newlines are significant :sweat_smile:
not sure it's worth it
Sounds like the formatter can/does replace that space with a newline?
yeah
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.
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:
Richard Feldman said:
it's fair about not liking significant whitespace, but if I know what
foo |> bardoes and I see (or write) the rarefoo |> bar.baz()for the first time, I think I'd look at that and be pretty confident that it meantfoo |> (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.
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
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:
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.
and actually come to think of it, it's not even new :thinking:
foo.bar already does something different from foo .bar
so really it's just declining to special-case it for |>
Richard Feldman said:
foo.baralready does something different fromfoo .bar
I thought that was a bug though... like we introduced parens and commas so things like this would always have one meaning
I don't feel strongly about any of this... I just feel a little suspicious of wss
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
I like a plan :grinning_face_with_smiling_eyes:
:nerd: Pinch me, I must be dreaming. :pizza:
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
Oh, that actually does seem quite useful. Thanks!
I go away for three days and return to |> making a comeback... you guys! :in_love:
@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.
(this is already how it works on main now, just wanted to document my reasoning for future reference)
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)
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