Skip to content

Pipes (|>)

a |> f(arg) is exactly f(a, arg). Left-associative; the pipe argument fills the first positional slot of the next call.

Syntax

EXPR "|>" CALL

Where CALL is a function call expression. Chain freely:

let cleaned: str = raw |> str.strip() |> str.lower() |> str.replace(",", "")

Desugaring

result = x |> normalise() |> scale(2.0) |> clamp(0.0, 1.0)

No partial application, no curry, no special placeholder. Just first-positional threading.

Precedence and positions

A |> chain must end its syntactic slot. Arithmetic (and calls/attribute access) may feed into the pipe on the left, but no operator may follow the chain on the same level — a comparison, boolean-op, arithmetic or ternary tail is a parse error. Parenthesise the chain to use its value further.

A slot is any single expression position: one call argument, a list / set / tuple element, a dict key or value, a subscript, a keyword-argument or default value, a lambda body, a comprehension part, a return / assignment value, an if / elif / while / for … in / assert header, or an f-string field. The pipe only takes the expression inside its own slot:

1 + 2 |> f() # ✅ == f(1 + 2)
g(3 |> f()) # ✅ pipes nest in call args
h(a, b |> f()) # ✅ == h(a, f(b)) — only its own argument
return x |> f() # ✅ a bare chain may end a return
if 3 |> f(): # ✅ a chain may be a header
(x |> f()) == 0 # ✅ parenthesised: compare the piped value
3 |> f() + 1 # ❌ parse error — parenthesise as above
3 |> f() == 30 # ❌ parse error — parenthesise as above
3 |> f() if cond else 4 |> g() # ❌ parse error — parenthesise each arm

When in doubt, parenthesise.

What’s allowed on the right

The right-hand side must be a call expression. A bare identifier is not enough:

x |> str.upper # ❌ — not a call
x |> str.upper() # ✅ — pipe fills the first positional slot

Methods accessed on the value itself are allowed, but the pipe still threads into the first positional slot of the resulting call — which is the receiver in method form:

"hello" |> str.upper() # str.upper("hello") → "HELLO"
"hello".upper() # also "HELLO" — pipes don't replace methods

When pipes shine

  • Long data-cleaning chains (strings, lists, dataframes).
  • Validation pipelines where each step returns the next stage’s input.
  • Anywhere the inside-out call form is the reading bottleneck.

For one-step transformations, plain f(x) is fine.

Common patterns

String pipelines

let slug: str = (
title
|> str.lower()
|> str.strip()
|> str.replace(" ", "-")
)

Multi-line pipe chains must sit inside parentheses — Typhon’s lexer is otherwise indentation-sensitive on |>, the same way Python is for any multi-line expression.

List pipelines

let total: int = sum(
items
|> filter(lambda i: i.active)
|> map(lambda i: i.weight)
)

(filter and map return iterators; either pipe into sum() / list() / tuple() as the terminal step, or wrap the chain in list(...) if you need a materialised list.)

Numerical pipelines

let smoothed: np.ndarray = signal |> np.convolve(kernel, mode="same") |> np.clip(0, 1)

What pipes are not

  • Not partial application. There is no f(_, b) placeholder. The pipe always fills slot 0.
  • Not curry. No f(a)(b).
  • Not type-changing. The output type is whatever the call returns.
  • Not async-special. Pipe an async value and you’ll need to await.

Where next