Pipelines
The pipeline operators pass the value on the left to the function on the right as an argument, so data transformations line up left to right in reading order. All four pipeline operators share the |> mark; a prefix or suffix distinguishes the semantics:
| Form | Expansion | Purpose |
|---|---|---|
a |> f | f(a) | basic pipeline |
a ?|> f | if a is null, short-circuits to null; otherwise f(a) | null-safe pipeline |
a |>? f | try? f(a) | fallible pipeline |
a |>! f | try! f(a) | propagating pipeline |
The mnemonic belongs to the same family as the null-conditional operators: a ? prefix means null check (?., ?[, ?? are one family), a ? suffix means fallible (matching try?), and a ! suffix means failure propagation (matching try!).
The Basic Pipeline
a |> f is equivalent to f(a). Chained pipelines flow left to right; 5 |> Dbl |> Dbl is Dbl(Dbl(5)):
func Dbl(n: int) -> int => n * 2;
print(5 |> Dbl |> Dbl);20A lambda variable on the right works the same:
val twice = (n: int) => n * 2;
print(3 |> twice |> twice);12The Argument-Carrying Form
a |> f(x) is equivalent to f(a, x): the left value is inserted into the first argument slot, and the parentheses hold the remaining arguments:
func Add(a: int, b: int) -> int => a + b;
print(5 |> Add(10));15To put the left value in a different slot, use the placeholder from the next section; no signature change is needed.
The Placeholder
This section runs its examples on this data and three helper functions:
val xs = [1, 5, 9, 12];
val s = " ab ";
func Between(lo: int, src: int[], hi: int) -> list<int>
{
val r = new list<int>();
for v in src
{
if v >= lo && v <= hi
{
r.Add(v);
}
}
return r;
}
func Repeat(s: string, n: int) -> string
{
var r = "";
for i in 0..n
{
r += s;
}
return r;
}
func Run(action: () -> void)
{
action();
}In a pipeline target expression, _ is a placeholder for the left value: the left value lands wherever _ stands. Once the target contains _, the left value no longer inserts as the first argument by default.
Argument position, receiver position, and indexer chains can all be marked, and multiple _ always stand for the same left value:
print(xs |> Between(2, _, 10)); // middle argument: Between(2, xs, 10)
print("ab" |> Repeat(_, 3)); // first argument, marked explicitly: Repeat("ab", 3)
print(s |> _.Trim().ToUpper()); // receiver position
print(xs |> _[0]); // indexer position
print(xs |> _[_.Length - 1]); // both _ are the same value[5, 9]
ababab
AB
1
12_ reaches into nested lambda bodies and slice chains:
xs |> Run(() => print("length " + _.Length));
print(xs |> _[..2]);length 4
[1, 5]A pure method chain can stay as written, s.Trim().ToUpper(); no pipeline needed. Long chains strung together from free functions are where pipelines earn their keep.
Null-Safe Pipeline ?|>
a ?|> f: if the left side is null, the pipeline short-circuits to null and the right side never runs; only non-null calls f(a). The result is always a nullable type, and non-nullable sources pass through silently — same rules as ?.:
func Wrap(s: string?) -> string? => s == null ? null : "[" + s + "]";
func Shout(s: string?) -> string? => s?.ToUpper();
val name: string? = "mush";
print(name ?|> Wrap ?|> Shout);
val none: string? = null;
print(none ?|> Wrap ?|> Shout);
print((none ?|> Wrap) ?? "<empty>");[MUSH]
null
<empty>The middle line, none ?|> Wrap ?|> Shout, short-circuits at the first link; Shout never runs. A chained null-safe pipeline carries null all the way to the end, with no per-link checks in between.
Each pipeline segment’s left operand is evaluated exactly once (zero times when short-circuited); a left side with side effects does not run twice under a null-safe pipeline.
Because the output is always nullable, the next link’s parameter must be declared T? (Shout takes string?).
To get a non-null value back, fall back with ??. ?? binds tighter than the pipeline, so when mixing the two, parenthesize the whole pipeline: (none ?|> Wrap) ?? "<empty>".
The argument-carrying form applies here too: s ?|> Pad(">>") is the null-safe version of s |> Pad(">>").
Fallible Pipeline |>?
a |>? f is equivalent to try? f(a): f must declare a fail clause, and on failure the whole pipeline yields null instead of propagating upward:
error Bad(n: int);
func Risky(n: int) fail Bad -> int
{
if n < 0
{
fail new Bad(n);
}
return n;
}
print(5 |>? Risky);
print(-1 |>? Risky);
print((7 |>? Risky) ?? 0);5
null
7|>? only accepts targets with a failure surface: a named function that declares fail or a failable lambda, either works — the pipeline expands to try? at compile time. It is not a null check: hand it an int? on the left and it will not skip anything for you.
Propagating Pipeline |>!
a |>! f is equivalent to try! f(a): it asserts this step must succeed, passing the result through on success and taking the process down on the spot with the error on failure:
print(5 |>! Risky);
print(-1 |>! Risky);
print("never reached");5
mush: MS6000: Runtime error: runtime crash: try! assertion failed: 'Risky' invoked fail (error: Main.Bad(n=-1)).After the second line fails, the program exits immediately; the third line never runs. This pipeline suits call sites where failure means a programming error you do not intend to handle — the same role as try!.
Case Study: A Deeply Nested Function Chain
The processing chain has four free functions, each taking the data as its first argument. Without pipelines the only option is nesting layer on layer, read from the innermost call outward:
func Without(xs: list<int>, v: int) -> list<int>
{
val kept = new list<int>();
for x in xs
{
if x != v
{
kept.Add(x);
}
}
return kept;
}
func ClampTo(xs: list<int>, hi: int) -> list<int>
{
val capped = new list<int>();
for x in xs
{
if x > hi
{
capped.Add(hi);
}
else
{
capped.Add(x);
}
}
return capped;
}
func Energy(xs: list<int>) -> int
{
var total = 0;
for x in xs
{
total = total + x * x;
}
return total;
}
func Bar(v: int) -> string
{
var s = "";
for i in 0..v / 10
{
s = s + "▇";
}
return s;
}
val samples: list<int> = [3, -1, 7, 4, -2, 15];
val bar = Bar(Energy(ClampTo(Without(samples, 0), 9))); // four layers of parentheses, innermost outward
print(bar);▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇Each added transformation in the nested form adds another layer of parentheses; to read it, find the innermost call first, then back out layer by layer carrying the intermediate arguments.
The pipeline flattens the same chain into a left-to-right reading order: argument-carrying links keep only their remaining arguments in parentheses (Without(0), ClampTo(9)), and the left value inserts as the first argument automatically. Reading order is execution order, and a longer chain just means one more line:
val bar = samples
|> Without(0) // drop missing-data zeros
|> ClampTo(9) // clamp point by point, keep bars on scale
|> Energy // energy: sum of squares
|> Bar; // one block per ten points
print(bar);A single line is fine too:
val bar = samples |> Without(0) |> ClampTo(9) |> Energy |> Bar;
print(bar);▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇Trade-offs and Notes
- Pipeline precedence sits only above the ternary and assignment: arithmetic on either side needs no parentheses, and
1 + 2 |> DblisDbl(1 + 2). - A function value may only appear in call position (a direct call, a pipeline target, an argument, assigned to a variable of function type). Making it participate in arithmetic or
??is a compile error (5 |> Dbl + 1reports MS3104), so a fallback mixed with??must parenthesize the whole pipeline:(x ?|> F) ?? fallback. |>?and|>!only accept targets with a failure surface (a named function declaring fail or a failable lambda); the basic and null-safe pipelines have no such requirement.- None of the four pipeline operators can be overloaded.
