Pipelining might be my favorite programming language feature
129 comments
·April 21, 2025bnchrch
I'm personally someone who advocates for languages to keep their feature set small and shoot to achieve a finished feature set quickly.
However.
I would be lying if I didn't secretly wish that all languages adopted the `|>` syntax from Elixir.
```
params
|> Map.get("user")
|> create_user()
|> notify_admin()
```
Cyykratahk
We might be able to cross one more language off your wishlist soon, Javascript is on the way to getting a pipeline operator, the proposal is currently at Stage 2
https://github.com/tc39/proposal-pipeline-operator
I'm very excited for it.
chilmers
It also has barely seen any activity in years. It is going nowhere. The TC39 committee is utterly dysfunctional and anti-progress, and will not let any this or any other new syntax into JavaScript. Records and tuples has just been killed, despite being cited in surveys as a major missing feature[1]. Pattern matching is stuck in stage 1 and hasn't been presented since 2022. Ditto for type annotations and a million other things.
Our only hope is if TypeScript finally gives up on the broken TC39 process and starts to implement its own syntax enhancements again.
[1] https://2024.stateofjs.com/en-US/usage/#top_currently_missin...
TehShrike
I was excited for that proposal, but it veered off course some years ago – some TC39 members have stuck to the position that without member property support or async/await support, they will not let the feature move forward.
It seems like most people are just asking for the simple function piping everyone expects from the |> syntax, but that doesn't look likely to happen.
packetlost
I don't actually see why `|> await foo(bar)` wouldn't be acceptable if you must support futures.
I'm not a JS dev so idk what member property support is.
zdragnar
I worry about "soon" here. I've been excited for this proposal for years now (8 maybe? I forget), and I'm not sure it'll ever actually get traction at this point.
hoppp
Cool I love it, but another thing we will need polyfills for...
bobbylarrybobby
How do you polyfill syntax?
valenterry
I prefer Scala. You can write
``` params.get("user") |> create_user |> notify_admin ```
Even more concise and it doesn't even require a special language feature, it's just regular syntax of the language ( |> is a method like .get(...) so you could even write `params.get("user").|>(create_user) if you wanted to)
elbasti
In elixir, ```Map.get("user") |> create_user |> notify_admin ``` would aso be valid, standard elixir, just not idiomatic (parens are optional, but preferred in most cases, and one-line pipes are also frowned upon except for scripting).
jasperry
Yes, a small feature set is important, and adding the functional-style pipe to languages that already have chaining with the dot seems to clutter up the design space. However, dot-chaining has the severe limitation that you can only pass to the first or "this" argument.
Is there any language with a single feature that gives the best of both worlds?
bnchrch
FWIW you can pass to other arguments than first in this syntax
```
params
|> Map.get("user")
|> create_user()
|> (¬ify_admin("signup", &1)).() ```
or
```
params
|> Map.get("user")
|> create_user()
|> (fn user -> notify_admin("signup", user) end).() ```
layer8
It would be even better without the `>`, though. The `|>` is a bit awkward to type, and more noisy visually.
MyOutfitIsVague
I disagree, because then it can be very ambiguous with an existing `|` operator. The language has to be able to tell that this is a pipeline and not doing a bitwise or operation on the output of multiple functions.
layer8
Yes, I’m talking about a language where `|` would be the pipe operator and nothing else, like in a shell. Retrofitting a new operator into an existing language tends to be suboptimal.
Symmetry
I feel like Haskell really missed a trick by having $ not go the other way, though it's trivial to make your own symbol that goes the other way.
jose_zap
Haskell has & which goes the other way:
users
& map validate
& catMaybes
& mapM persist
taolson
Yes, `&` (reverse apply) is equivalent to `|>`, but it is interesting that there is no common operator for reversed compose `.`, so function compositions are still read right-to-left.
In my programming language, I added `.>` as a reverse-compose operator, so pipelines of function compositions can also be read uniformly left-to-right, e.g.
process = map validate .> catMaybes .> mapM persist
Symmetry
I guess I'm showing how long it's been since I was a student of Haskell then. Glad to see the addition!
duped
A pipeline operator is just partial application with less power. You should be able to bind any number of arguments to any places in order to create a new function and "pipe" its output(s) to any other number of functions.
One day, we'll (re)discover that partial application is actually incredibly useful for writing programs and (non-Haskell) languages will start with it as the primitive for composing programs instead of finding out that it would be nice later, and bolting on a restricted subset of the feature.
SimonDorfman
The tidyverse folks in R have been using that for a while: https://magrittr.tidyverse.org/reference/pipe.html
madcaptenor
And base R has had a pipe for a couple years now, although there are some differences between base R's |> and tidyverse's %>%: https://www.tidyverse.org/blog/2023/04/base-vs-magrittr-pipe...
thom
I've always found magrittr mildly hilarious. R has vestigial Lisp DNA, but somehow the R implementation of pipes was incredibly long, complex and produced stack traces, so it moved to a native C implementation, which nevertheless has to manipulate the SEXPs that secretly underlie the language. Compared to something like Clojure's threading macros it's wild how much work is needed.
flobosg
Base R as well: |> was implemented as a pipe operator in 4.1.0.
epolanski
I personally like how effect-ts allows you to write both pipelines or imperative code to express the very same things.
Building pipelines:
https://effect.website/docs/getting-started/building-pipelin...
Using generators:
https://effect.website/docs/getting-started/using-generators...
Having both options is great (at the beginning effect had only pipe-based pipelines), after years of writing effect I'm convinced that most of the time you'd rather write and read imperative code than pipelines which definitely have their place in code bases.
In fact most of the community, at large, converged at using imperative-style generators over pipelines and having onboarded many devs and having seen many long-time pipeliners converging to classical imperative control flow seems to confirm both debugging and maintenance seem easier.
kordlessagain
While the author claims "semantics beat syntax every day of the week," the entire article focuses on syntax preferences rather than semantic differences.
Pipelining can become hard to debug when chains get very long. The author doesn't address how hard it can be to identify which step in a long chain caused an error.
They do make fun of Python, however. But don't say much about why they don't like it other than showing a low-res photo of a rock with a pipe routed around it.
Ambiguity about what constitutes "pipelining" is the real issue here. The definition keeps shifting throughout the article. Is it method chaining? Operator overloading? First-class functions? The author uses examples that function very differently.
Mond_
> Pipelining can become hard to debug when chains get very long. The author doesn't address how hard it can be to identify which step in a long chain caused an error.
Yeah, I agree that this can be problem when you lean heavily into monadic handling (i.e. you have fallible operations and then pipe the error or null all the way through, losing the information of where it came from).
But that doesn't have much to do with the article: You have the same problem with non-pipelined functional code. (And in either case, I think that it's not that big of a problem in practice.)
> The author uses examples that function very differently.
Yeah, this is addressed in one of the later sections. Imo, having a unified word for such a convenience feature (no matter how it's implemented) is better than thinking of these features as completely separate.
zelphirkalt
You can add peek steps in pipelines and inspect the in between results. Not really any different from normal function call debugging imo.
krapht
Yes, but here's my hot take - what if you didn't have to edit the source code to debug it? Instead of chaining method calls you just assign to a temporary variable. Then you can set breakpoints and inspect variable values like you do normally without editing source.
It's not like you lose that much readability from
foo(bar(baz(c)))
c |> baz |> bar |> foo
c.baz().bar().foo()
t = c.baz()
t = t.bar()
t = t.foo()
Mond_
I feel like a sufficiently good debugger should allow you to place a breakpoint at any of the lines here, and it should break exactly at that specific line.
fn get_ids(data: Vec<Widget>) -> Vec<Id> {
data.iter()
.filter(|w| w.alive)
.map(|w| w.id)
.collect()
}
It sounds to me like you're asking for linebreaks. Chaining doesn't seem to be the issue here.erichocean
The Clojure equivalent of `c |> baz |> bar |> foo` are the threading macros:
(-> c baz bar foo)
But people usually put it on separate lines: (-> c
baz
bar
foo)
AYBABTME
It's just as difficult to debug when function calls are nested inline instead of assigning to variables and passing the variables around.
fsckboy
the paragraph you quoted (atm, 7 mins ago, did it change?) says:
>Let me make it very clear: This is [not an] article it's a hot take about syntax. In practice, semantics beat syntax every day of the week. In other words, don’t take it too seriously.
bena
I think you may have misinterpreted his motive here.
Just before that statement, he says that it is an article/hot take about syntax. He acknowledges your point.
So I think when he says "semantics beat syntax every day of the week", that's him acknowledging that while he prefers certain syntax, it may not be the best for a given situation.
pavel_lishin
The article also clearly points that that it's just a hot-take, and to not take it too seriously.
0xf00ff00f
First example doesn't look bad in C++23:
auto get_ids(std::span<const Widget> data)
{
return data
| filter(&Widget::alive)
| transform(&Widget::id)
| to<std::vector>();
}
inetknght
This is not functionally different from operator<< which std::cout has taught us is a neat trick but generally a bad idea.
senderista
Unlike the iostreams shift operators, the ranges pipe operator isn't stateful.
flakiness
After seeing LangChain abusing the "|" operator overload for pipeline-like DSL, I followed the suite at work and I loved it. It's especially good when you use it in a notebook environment where you literally build the pipeline incrementally through repl.
mrkeen
data.iter()
.filter(|w| w.alive)
.map(|w| w.id)
.collect()
collect(map(filter(iter(data), |w| w.alive), |w| w.id))
The second approach is open for extension - it allows you to write new functions on old datatypes.> Quick challenge for the curious Rustacean, can you explain why we cannot rewrite the above code like this, even if we import all of the symbols?
Probably for lack of
> weird operators like <$>, <*>, $, or >>=
esafak
Extension methods to the rescue: https://en.wikipedia.org/wiki/Extension_method
Examples:
https://kotlinlang.org/docs/extensions.html
https://docs.scala-lang.org/scala3/reference/contextual/exte...
See also: https://en.wikipedia.org/wiki/Uniform_function_call_syntax
rikthevik
Came here for the Uniform function call syntax link. This is one of the little choices that has a big impact on a language! I love it!
I wrote a little pipeline macro in https://nim-lang.org/ for Advent of Code years ago and as far as I know it worked okay.
``` import macros
macro `|>`\* (left, right : expr): expr =
result = newNimNode(nnkCall)
case right.kind
of nnkCall:
result.add(right[0])
result.add(left)
for i in 1..<right.len:
result.add(right[i])
else:
error("Unsupported node type")
```Makes me want to go write more nim.
vips7L
I really wish you couldn't write extensions on nullable types. It's confusing to be able to call what look like instance functions on something clearly nullable without checking.
fun main() {
val s: String? = null
println(s.isS()) // false
}
fun String?.isS() = "s" == this
Mond_
> The second approach is open for extension - it allows you to write new functions on old datatypes.
I prefer to just generalize the function (make it generic, leverage traits/typeclasses) tbh.
> Probably for lack of > weird operators like <$>, <*>, $, or >>=
Nope btw. I mean, maybe? I don't know Haskell well enough to say. The answer that I was looking for here is a specific Rust idiosyncrasy. It doesn't allow you to import `std::iter::Iterator::collect` on its own. It's an associated function, and needs to be qualified. (So you need to write `Iterator::collect` at the very least.)
higherhalf
> It doesn't allow you to import `std::iter::Iterator::collect` on its own. It's an associated function, and needs to be qualified.
You probably noticed, but it should become a thing in RFC 3591: https://github.com/rust-lang/rust/issues/134691
So it does kind of work on current nightly:
#![feature(import_trait_associated_functions)]
use std::iter::Iterator::{filter, map, collect};
fn get_ids2(data: Vec<Widget>) -> Vec<Id> {
collect(map(filter(Vec::into_iter(data), |w| w.alive), |w| w.id))
}
fn get_ids3(data: impl Iterator<Item = Widget>) -> Vec<Id> {
collect(map(filter(data, |w| w.alive), |w| w.id))
}
Mond_
Oh, interesting! Thank you, I did not know about that, actually.
RHSeeger
I feel like, at least in some cases, the article is going out of its way to make the "undesired" look worse than it needs to be. Compairing
fn get_ids(data: Vec<Widget>) -> Vec<Id> {
collect(map(filter(map(iter(data), |w| w.toWingding()), |w| w.alive), |w| w.id))
}
to fn get_ids(data: Vec<Widget>) -> Vec<Id> {
data.iter()
.map(|w| w.toWingding())
.filter(|w| w.alive)
.map(|w| w.id)
.collect()
}
The first one would read more easily (and, since it called out, diff better) fn get_ids(data: Vec<Widget>) -> Vec<Id> {
collect(
map(
filter(
map(iter(data), |w| w.toWingding()), |w| w.alive), |w| w.id))
}
Admittedly, the chaining is still better. But a fair number of the article's complaints are about the lack of newlines being used; not about chaining itself.the_sleaze_
In my eyes newlines don't solve what I feel to be the issue. Reader needs to recognize reading from left->right to right->left.
Of course this really only matters when you're 25 minutes into critical downtime and a bug is hiding somewhere in these method chains. Anything that is surprising needs to go.
IMHO it would be better to set intermediate variables with dead simple names instead of newlines.
fn get_ids(data: Vec<Widget>) -> Vec<Id> {
let iter = iter(data);
let wingdings = map(iter, |w| w.toWingding());
let alive_wingdings = filter(wingdings, |w| w.alive);
let ids = map(alive_wingdings, |w| w.id);
let collected = collect(ids);
collected
}
tasuki
Oh wow, are we living in the same universe? To me the one-line example and your example with line breaks... they just... look about the same?
See how adding line breaks still keeps the `|w| w.alive` very far from the `filter` call? And the `|w| w.id` very far from the `map` call?
If you don't have the pipeline operator, please at least format it something like this:
fn get_ids(data: Vec<Widget>) -> Vec<Id> {
collect(
map(
filter(
map(
iter(data),
|w| w.toWingding()
),
|w| w.alive
),
|w| w.id
)
)
}
...which is still absolutely atrocious both to write and to read!Also see how this still reads fine despite being one line:
fn get_ids(data: Vec<Widget>) -> Vec<Id> {
data.iter().map(|w| w.toWingding()).filter(|w| w.alive).map(|w| w.id).collect()
}
It's not about line breaks, it's about the order of applying the operations, and about the parameters to the operations you're performing.RHSeeger
> It's not about line breaks, it's about the order of applying the operations
For me, it's both. Honestly, I find it much less readable the way you're split it up. The way I had it makes it very easy for me to read it in reverse; map, filter, map, collect
> Also see how this still reads fine despite being one line
It doesn't read fine, to me. I have to spend mental effort figuring out what the various "steps" are. Effort that I don't need to spend when they're split across lines.
For me, it's a "forest for the trees" kind of thing. I like being able to look at the code casually and see what it's doing at a high level. Then, if I want to see the details, I can look more closely at the code.
TOGoS
They did touch on that.
> You might think that this issue is just about trying to cram everything onto a single line, but frankly, trying to move away from that doesn’t help much. It will still mess up your git diffs and the blame layer.
Diff will still be terrible because adding a step will change the indentation of everything 'before it' (which, somewhat confusingly, are below it syntactically) in the chain.
RHSeeger
Diff can ignore whitespace, so not really an issue. Not _as_ nice, but not really a problem.
osigurdson
C# has had "Pipelining" (aka Linq) for 17 years. I do miss this kind of stuff in Go a little.
bob1029
I don't see how LINQ provides an especially illuminating example of what is effectively method chaining.
It is an exemplar of expressions [0] more than anything else, which have little to do with the idea of passing results from one method to another.
[0]: https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...
hahn-kev
So many things have been called Linq over the years it's hard to talk about at this point. I've written C# for many years now and I'm not even sure what I would say it's referring to, so I avoid the term.
In this case I would say extension methods are what he's really referring to, of which Linq to objects is built on top of.
delusional
You might be talking about LINQ queries, while the person you are responding to is probably talking about LINQ in Method Syntax[1]
[1]: https://learn.microsoft.com/en-us/dotnet/csharp/linq/get-sta...
vjvjvjvjghv
Agreed. It would be nice if SQL databases supported something similar.
sidpatil
PRQL [1] is a pipeline-based query language that compiles to SQL.
NortySpock
I've used "a series of CTEs" to apply a series of transformations and filters, but it's not nearly as elegant as the pipe syntax.
cutler
Clojure has pipeline functions -> and ->> without resorting to OO dot syntax.
jolt42
As well as some-> (exit on null) and cond-> (with predicates) that are often handy.
joeevans1000
As well as a lot of flexibility on where the result of the previous step feeds into the current one.
That new Rhombus language that was featured here recently has an interesting feature where you can use `_` in a function call to act as a "placeholder" for an argument. Essentially it's an easy way to partially apply a function. This works very well with piping because it allows you to pipe into any argument of a function (including optional arguments iirc) rather than just the first like many pipe implementations have. It seems really cool!