1. |> Pipeline Operator -> Absolutely needed for superior APIs/implementations in certain cases, otherwise quite limited
I've built an advanced universal test runner and although it is advanced, I can't STILL use the pipeline operator after MANY YEARS!! Adding it would be a massive hack and degrade the performance of my binary and availability of the tools to me SIGNIFICANTLY. Thank you TC39 people and highly cynical reviewers for blocking this amazingly useful and important feature FOR YEARS!
Can I use it now?
not natively, anywhere, and not in this project even with a library. I tested every tool in your pipeline rather than going from memory:
ββββββββββββββ¬βββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β Tool β Version β 5 |> double β
ββββββββββββββΌβββββββββββΌββββββββββββββββββββββββββββββββββββ€
β Node β 24.16.0 β SyntaxError: Unexpected token '>' β
ββββββββββββββΌβββββββββββΌββββββββββββββββββββββββββββββββββββ€
β Deno β 2.9.2 β SyntaxError: Unexpected token '>' β
ββββββββββββββΌβββββββββββΌββββββββββββββββββββββββββββββββββββ€
β TypeScript β 7.0.2 β TS1109: Expression expected β
ββββββββββββββΌβββββββββββΌββββββββββββββββββββββββββββββββββββ€
β esbuild β (bundle) β Unexpected ">" β
ββββββββββββββΌβββββββββββΌββββββββββββββββββββββββββββββββββββ€
β deno check β β β SyntaxError: Expression expected β
ββββββββββββββΌβββββββββββΌββββββββββββββββββββββββββββββββββββ€
β deno lint β β β SyntaxError: Expression expected β
ββββββββββββββ΄βββββββββββ΄ββββββββββββββββββββββββββββββββββββ
The proposal is still Stage 2 at TC39 and has been for years β stalled on the Hack-pipe (5 |> double(%)) vs F#-pipe (5 |> double) argument, which is exactly the kind of disagreement that keeps a proposal parked. V8 implements nothing below Stage 3, so Node and Deno both inherit "no." TypeScript's policy is Stage 3 minimum for new syntax, so it won't parse it either β and note it isn't just a type-check failure: tsc can't even parse the file.
Why a library can't rescue it here
Babel can transform it β @babel/plugin-proposal-pipeline-operator with { proposal: "hack" }. But look at what would have to run through Babel first for your CI to stay green:
- node cli.ts β you run TypeScript directly via Node's native type-stripping. Tnnot transform syntax. Your ~88ms startup and the V8 compile cache both depend onthis path. Adding Babel means a build step before Node sees anything, and that headline number is gone.
- deno check / deno lint / deno test --doc β four of the eight steps in your lis |> outright, and there's no plugin hook to change that. test:doctest parses the```ts blocks in your JSDoc, so even a pipeline in a doc example fails.
- esbuild β your browser bundle. Confirmed above: it can't parse it and has no sign.
- deno publish for JSR β parses and type-checks your public API. Same wall.
- prettier β silently passed my test only because it fell back to leaving the lperly needs the Babel parser with the plugin enabled, which conflicts with your--parser=typescript setup.
So the honest setup would be: Babel over lib/ + test/ + benches/, emitting to a build directory, then point Node, Deno, esbuild, JSR, the doctest gate and prettier at that β and rewrite the workflow. You'd be trading your entire zero-build toolchain for one operator.
You already have the Elixir ergonomics
This is the part I'd actually press. |> in Elixir exists because Elixir has no m() is the only way to read left-to-right. You have methods, and you already builtboth halves:
// method chain β reads exactly like a pipeline
return Task(() => fs.readFile(filePath, 'utf8'))
.map((raw) => JSON.parse(raw))
.recover(() => null);
// and the data-first twins, which ARE the Elixir module style
Task.map(task, fn) // β‘ task.map(fn)
Task.mapErr(task, fn) // β‘ task.mapErr(fn)Stream has .through() for injecting arbitrary transforms, which is the one thing to express:
stream.through(customStage).map(parse).partition();With |> that same code would be stream |> customStage |> ... β the same order, to toolchain.
My recommendation: no. Not "not yet, revisit at Stage 3" β even at Stage 3 you'd, then esbuild, then TypeScript, and the win over .map().through() is punctuation. Worth re-checking whether it moved past Stage 2 since my information cuts off around May 2026, but nothing I tested today suggests it has.
The shape of the problem
const config = await Config.setup().result();
if (Failure.is(config)) {
console.error(Failure.format(config));
return exitAfterFlush(1);
}
// config: Config from hereThree lines of ceremony whose only job is to split one binding into two paths. The value is already self-describing β the language just has no way to say "bind the good one, and here's what to do with the other."
Proposal: extend the existing declarators, don't add a keyword
const x = <init> else (failure) { β¦ }
const config = await Config.setup() else (failure) {
console.error(Failure.format(failure));
return exitAfterFlush(1);
};
// config: Config β no guard, no narrowing dance
I would not mint a new binding keyword. var/let/const already cost the ecosystem a decade of teaching; a fourth would fragment every linter, every codemod, and every model's prior. This is a clause on the existing declarator, so const keeps meaning exactly what it means.
Why it can't break the web: const x = 1 else {} is a SyntaxError today. Nothing valid changes meaning. That's the whole don't-break-the-web test, and this passes it cleanly.
Why else and not the alternatives
or β reads beautifully, and is a trap:
const x = await f() or
Why else and not the alternatives
or reads beautifully and is a trap:
const x = await f()
or (e) { β¦ }
or isn't reserved, so that's already valid JS today β a call to a function named or, followed by a block. ASI makes it genuinely ambiguous, and any proposal that changes the meaning of a currently-valid program is dead at committee. Same fate for otherwise, fallback, onfail.
catch is reserved and reads well β and is semantically wrong, which is the more interesting objection:
const config = await Config.setup() catch (failure) { β¦ } // β
That word says "an exception happened." Nothing was thrown. The entire two-tier design turns on the distinction between a declared failure travelling as a value and a bug travelling as an exception. Spending the word catch on the value channel would erase the distinction in the mind of every reader β and every model trained on the corpus. catch must stay expensive.
else is already reserved (safe), and already means "the other branch." Zero new vocabulary for humans or LLMs: anyone who knows if/else knows what the failure branch is on sight.
The one hard rule
The else block must complete abruptly β return, throw, break, continue. Otherwise control would fall through to code where x was never bound.
const config = await Config.setup() else (failure) {
console.error(Failure.format(failure)); // β SyntaxError: else clause must not complete normally
};
This is exactly Rust's let-else rule (stabilised 1.65), and it's statically checkable β the same analysis engines already do for TDZ. That makes it an early error, not a runtime surprise.
It also composes with loops, which is where it earns its keep:
for (const row of rows) {
const parsed = parseRow(row) else (failure) { warnings.push(failure); continue; };
emit(parsed); // parsed: Row, unconditionally
}
How the engine knows
Failure would need a brand the language owns. I'd expose it as a well-known symbol rather than a hard-coded class check:
class HttpFailure {
get [Symbol.failure]() { return true; }
}Same extensibility story as Symbol.iterator β the else clause tests Symbol.failure, so userland types opt in and nothing is privileged. It also survives the thing instanceof can't: structured clone and JSON revival across a worker or socket boundary, which is exactly the hole we walked through earlier.
What I would refuse to add
A propagate operator β Rust's ?. In Rust it works because Result is in the return type, so ? is visible in the signature. JavaScript has no such signature, so foo()? would be an invisible throw: punctuation that changes control flow with nothing in the declaration to warn a reader. That's precisely the failure mode the two-tier rule exists to prevent. Propagation should stay a named call β Result.unwrap(β¦), the one I just committed.
I also considered and dropped a dual form, const port = parsePort(raw) else 3000 for fallbacks. else { β¦ } as a block versus else {a: 1} as an object literal is the arrow-function-returning-object ambiguity all over again, and one syntax with two semantics is worse than unwrapOr.
Name: else. Nothing new to learn, nothing on the web to break, and the word already means what it needs to mean.
The shape of the problem
The else clause solved the two-way split. It does nothing for the n-way one, which is where the ceremony actually accumulates:
const res = await fetchUser(id);
if (Failure.is(res)) {
if (res.status === 404) return null;
if (res.status >= 500) return retry(id);
throw res;
}
return res.body;Six lines, four of them re-interrogating the same value, each one re-narrowing from scratch. The information "what shape is this" is computed over and over because the language has no way to ask once.
The unifying frame
I would not propose "pattern matching" as a feature. I'd propose one pattern grammar and three places that consume it:
ββββββββββββββββββ¬βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ
β Consumer β Question β Result β
ββββββββββββββββββΌβββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ€
β is β is it this shape? β boolean, narrows β
ββββββββββββββββββΌβββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ€
β match β which of these shapes? β value, one binding set per clause β
ββββββββββββββββββΌβββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ€
β β¦ else (e) { } β this shape, or bail β bindings, or abrupt completion β
ββββββββββββββββββ΄βββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ
That is the whole design. Three surface forms, one thing to learn. It also means the else clause from before isn't a separate feature that happens to sit nearby β it's the irrefutable-bind consumer of the same grammar, and const [head, ...tail] = xs else { return [] } comes free.
return match (await fetchUser(id)) {
when Failure({ status: 404 }): null;
when Failure({ status }) if (status >= 500): retry(id);
when Failure(f): raise(f);
else (user): user.body;
};
Why it can't break the web
Three hazards, all real, all fixed by the same trick JS already uses for return, throw, postfix ++, =>, and async function: the restricted production, [no LineTerminator here].
- is has the or problem. is isn't reserved, so this is valid today:
const x = a
is(b) // β const x = a; is(b); ASI inserts the semicolonMake is a binary operator and that program silently changes meaning. Fix: no LineTerminator before is. The ASI-dependent program keeps its meaning; a is B on one line was a SyntaxError before, so nothing valid is touched.
- match (x) { β¦ } is a call followed by a block. Also valid today β but only with a newline:
match (foo)
{ bar } // call, then block
On one line, match (foo) { bar } is already a SyntaxError, because ASI needs the newline. So the fix is narrow: the header match (, and the ) { join, must have no LineTerminator. Everyone writes switch (x) { on one line already, so the constraint costs nothing and every currently-valid program survives.
- | inside a pattern. x is 1 | 2 reads as bitwise-or to anyone applying expression precedence. It isn't ambiguous β the RHS of is is a pattern, its own grammar production, where | is alternation and there is no bitwise anything. But it's the one place where reading the code with expression instincts gives the wrong model, so it deserves to be taught explicitly rather than discovered.
The pattern grammar
{ a, b } // partial. binds a, b. never throws on null
{ status: 200 } // partial. tests
{ a: _ } // tests presence only
[a, b] // exact length
[a, ...rest] // open
1 | 2 | 3 // alternation; every branch must bind the same names
${expected} // tests against the *value* of expected
pattern as name // binds the whole and the parts
_ // wildcard
Failure // custom matcher, boolean form
Failure({ status }) // custom matcher, extractor form
Four decisions in there are load-bearing.
Bare identifiers always bind. Never test. This is Rust's worst footgun and I'd refuse to inherit it: in Rust, match x { MAX => β¦ } matches the constant MAX, while match x { max => β¦ } binds everything and matches unconditionally. Renaming a constant to lowercase silently changes control flow, caught only by a lint. Elixir got this right with ^; I'd spell it ${expected}, which already means "a value goes here" to every JS programmer alive and needs no new token.
Objects are partial, arrays are exact. Rust requires .. on structs; Elixir maps are partial and lists exact. Both languages independently split it the same way, and JS should too β an object is a bag of properties, an array's length is data.
An irrefutable pattern is only legal in else. when other: in the middle of a match is an early error: it makes every following clause dead. The payoff is that totality is answerable by inspection β a match is total iff it has an else clause β with no second spelling for the catch-all and no default: importing switch's fallthrough baggage.
No mid-list holes. Rust's [first, .., last] works because slices are random-access with known length. JS array patterns run against iterables, and matching [a, ..., b] against a generator means buffering all of it to learn what b is. Trailing ...rest only β the same rule destructuring already has.
What JS has to answer that Rust and Elixir never had to
This is where a port of either language's semantics goes wrong.
Property access is a function call. when { a: 1 }: β¦; when { a: 2 }: β¦ against a getter or a Proxy would invoke the trap twice, and could observe two different values, making the match's behavior depend on how many clauses precede it. The spec has to say: every Get performed by a match is memoized for the duration of that match. Observable, but stable and explainable. A userland implementation cannot fix this and shouldn't pretend to.
Iterators are consumed once. Same answer: elements drawn while testing clause 1 are cached for clauses 2..n, and no clause draws more than its pattern needs. [a, b] must draw a third element to prove exact length; [a, ...rest] against an infinite generator hangs, exactly as [...gen] does today.
Literal patterns use SameValueZero, not ===. switch (NaN) { case NaN: } never fires β a twenty-year-old wart nobody defends. when NaN: should match NaN, and -0 should match 0, consistent with includes, Map, and Set. Repeating switch's mistake for symmetry with switch would be the wrong kind of consistency.
Object patterns never throw on nullish. null is { a } is false. If matching could throw on the most common input in JS, nobody would use it on the values that need it most.
Guards are arbitrary expressions, and a throw inside one propagates. Elixir restricts guards to a pure whitelist, and an error raised in a guard is swallowed as "clause didn't match" β which is coherent there and impossible here, since JS has no notion of a total function. But swallowing is the part I'd refuse even if it were possible: a bug in a guard turning into a silently-skipped clause is precisely the two-tier violation the whole design exists to prevent. Guards may lie about matching; they may not eat bugs.
Strings are iterable, and array patterns should reject them anyway. "hi" is [a, b] being true is a trap with no upside. String structure goes through literal and regex patterns; array patterns require a non-string iterable.
Against Rust and Elixir, point by point
ββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Edge case β Rust β Elixir β Here β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β No clause matches β compile error β CaseClauseError β throws MatchError; TS makes it a compile error β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Bare identifier β binds β unless a const shadows it β binds β always binds β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Test against a variable β guard, or a const β ^x β ${x} β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Repeated name {a: x, b: x} β error β unifies by equality β early error β use ${} or a guard β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Or-patterns β 1 | 2, same bindings β none, repeat the clause β 1 | 2, same bindings β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Guards β arbitrary; panics propagate β pure whitelist; errors = no match β arbitrary; throws propagate β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Guard + exhaustiveness β guarded arm doesn't count β n/a β same, in TS β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Whole + parts β n @ pat β pat = n β pat as n β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Ranges β 1..=5 β guards β guards β JS has no range literal β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Mid-sequence hole β [a, .., b] β no β no β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Custom patterns β no (macros) β no β Symbol.customMatcher β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Bytes/strings β no β <<a::8, rest::binary>> β regex patterns; bytes via custom matcher β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Boolean form β matches! macro β no β is operator β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Binding modes β ref/ref mut auto-deref β n/a β n/a β no ownership, so this whole category vanishes β
ββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Two of these are worth saying out loud. Rust needs the matches! macro because match is too heavy to ask a yes/no question; giving JS is as real syntax deletes that entire category. And Rust's default binding modes β the auto-ref rules that make match &opt behave differently from match opt β are the single most confusing part of Rust patterns, and they exist purely to serve ownership. JS gets that simplification for free.
What I would refuse to add
Elixir's = as a match operator. {:ok, x} = f() asserting-and-binding is beautiful in a language built on it. Retrofitting it means const { a: 1 } = x throws where today it's a no-op, and that's a breaking change with no migration.
A with chain. Elixir needs with because it has no early return. JS has one, and a sequence of else-guarded bindings is with unrolled β with the property Elixir's version notoriously lacks: each failure is handled at the line that produced it, instead of funneled into one fused else block that can't tell you which clause failed.
Multi-clause function definitions. Erlang/Elixir's headline feature, and the wrong shape here: it collides with hoisting, arguments, and TS overloads, and buys you a match on the parameter list that you can already write in one line.
Statement bodies. Clause bodies are expressions. when x: { β¦ } would need do expressions to have a value, so it waits for them. This is the design's one genuine ergonomic cost and I'd rather state it than paper over it with an IIFE.
Language or userland β the honest split
The binding is not polyfillable. A userland match takes one arrow per clause, which means no return from the enclosing function, no break/continue, and an allocation per clause per call. That's the part only syntax can fix.
The protocol is polyfillable, and that's the strategic move. Symbol.customMatcher is just a well-known symbol:
Symbol.customMatcher ??= Symbol("Symbol.customMatcher");
const Failure = {
[Symbol.customMatcher](subject) {
return subject?.[Symbol.failure] ? [subject] : false;
},
};Every type that implements it today keeps working unchanged the day the syntax lands, and the native symbol wins automatically. Types are the slow-moving part of an ecosystem; syntax is the fast part. Ship the slow part first.
So, concretely, in this order:
- Now. Implement Symbol.customMatcher on Failure and friends. Use a ts-pattern-shaped builder for the call sites β it already proves the type-level half works, including .exhaustive() as a real compile error. Restrict yourself to the pattern subset above so the eventual codemod is mechanical.
- TC39, in this order. is first: it's the smallest piece, it's the whole grammar's test bed, and it's independently valuable. Then match. Then generalize the else clause from an identifier to a full pattern.
- TypeScript, in parallel. is narrowing both branches, per-clause narrowing in match, and exhaustiveness as an error β with the caveat Rust also lives with, that a guarded clause can't count toward exhaustiveness. There's a happy accident here: x is { ok: true } at runtime means exactly what x is Foo already means in a TS predicate signature. Same word, same job.
What I would not do is reach for a Babel plugin to get the syntax early. Decorators taught that lesson twice.
Name: is, match, when, else. Three of the four are already reserved, the fourth is fixed by a restricted production the grammar already uses five times, and every one of them already means what it needs to mean.
β― Show me good examples of your proposal in the language in the future and in the meantime, in userland. Use very good example with also edge-cases shown
I pulled the real shapes out of this repo (lib/result/failure.ts, lib/setup/config.ts, lib/commands/run/tests-in-browser.ts:99) so the examples are code you actually have, not toys.
// cli.ts:85 does this inline today: console.error(Failure.format(config)) + exitAfterFlush(1).
// A clause body is an *expression*, so the helper it calls has to return `never` rather than
// fall off the end as a `void` statement. That's a real consequence of expression-bodied
// clauses, and it's the small tax you pay for `match` being a value.
declare function reportAndExit(message: string): never;
The userland kit β 55 lines, and the protocol is the part that survives
// lib/result/match.ts
export const CUSTOM_MATCHER: unique symbol =
((Symbol as { customMatcher?: symbol }).customMatcher ??= Symbol.for('Symbol.customMatcher')) as never;
export const _ = Symbol.for('match.wildcard'); // becomes `_`
export const pin = (v: unknown) => ({ [CUSTOM_MATCHER]: (s: unknown) => Object.is(s, v) && [] });
export function is(subject: unknown, pattern: unknown): boolean {
if (pattern === _) return true;
if (pattern !== null && (typeof pattern === 'object' || typeof pattern === 'function')) {
if (CUSTOM_MATCHER in pattern) return (pattern as never)[CUSTOM_MATCHER](subject) !== false;
if (typeof pattern === 'function') return Boolean(pattern(subject)); // predicate
if (pattern instanceof RegExp) return typeof subject === 'string' && pattern.test(subject);
if (Array.isArray(pattern)) {
return Array.isArray(subject) && subject.length === pattern.length
&& pattern.every((p, i) => is(subject[i], p));
}
if (subject === null || (typeof subject !== 'object' && typeof subject !== 'function')) return false;
return Object.entries(pattern).every(([k, p]) => k in (subject as object) && is((subject as never)[k], p));
}
return Object.is(pattern, subject) || (pattern === 0 && subject === 0); // SameValueZero
}
class Match<S, R> {
#subject: S; #hit = false; #value: R | undefined;
constructor(subject: S) { this.#subject = subject; }
when(pattern: unknown, a: Function, b?: Function): this {
const [guard, handler] = b ? [a, b] : [undefined, a];
if (!this.#hit && is(this.#subject, pattern) && (!guard || guard(this.#subject))) {
this.#hit = true;
this.#value = handler(this.#subject);
}
return this;
}
else(handler: (v: S) => R): R { return this.#hit ? this.#value! : handler(this.#subject); }
exhaustive(): R {
if (!this.#hit) throw new TypeError(`no clause matched: ${String(this.#subject)}`);
return this.#value!;
}
}
export const match = <S>(subject: S) => new Match<S, never>(subject);That's the whole runtime. The type-level half β per-clause narrowing and .exhaustive() as a compile error β is where ts-pattern's ~2k lines of conditional types earn their keep; use it if you want that today. The piece worth writing yourself is CUSTOM_MATCHER, because it's the only part that becomes native.
A. Config dispatch β lib/setup/config.ts
Future
const config = match (await Config.setup().result()) {
when { code: 'InvalidPlugins', data: { received } }:
reportAndExit(`qunitx.plugins must be an array, got ${received}`);
when { code: 'PluginLoadFailed', data: { path } } if (path.startsWith('./')):
reportAndExit(`local plugin ${path} failed to load β is the path right?`);
when { code: 'ProjectRootNotFound' }:
reportAndExit('no package.json found in this directory or any parent');
when Failure(f):
reportAndExit(Failure.format(f));
else (config):
config;
};
// config: Config
Today
const config = match(await Config.setup().result())
.when({ code: 'InvalidPlugins' },
(f) => reportAndExit(`qunitx.plugins must be an array, got ${f.data.received}`))
.when({ code: 'PluginLoadFailed' }, (f) => f.data.path.startsWith('./'),
(f) => reportAndExit(`local plugin ${f.data.path} failed to load`))
.when({ code: 'ProjectRootNotFound' }, () => reportAndExit('no package.json found'))
.when(Failure.is, (f) => reportAndExit(Failure.format(f)))
.else((config) => config);Note what the object pattern is doing: { code: 'InvalidPlugins' } is the whole discriminant check, and it works identically on a Failure that arrived over a WebSocket β which is exactly why failure.ts put code on the wire in the first place. Patterns and that decision were made for the same reason.
The gap between the two is one closure per clause and the fact that f.data.received has to be re-reached in the body instead of bound in the head.
B. deriveBuildErrorType β tests-in-browser.ts:99
The current implementation runs four regexes over a string it derives with a nested ternary. That is a pattern match written longhand.
Future
const AnError = { [Symbol.customMatcher]: (s) => s instanceof Error && [s] };
function deriveBuildErrorType(error: unknown): string {
const text = match (error) {
when { errors: [{ text }, ...] }: text; // at least one esbuild message
when AnError(e): e.message;
else (other): String(other);
};
return match (text) {
when /could not resolve|cannot find module|no such file/i: 'Module Resolution Error';
when /unexpected token|expected .* but found|unterminated/i: 'Syntax Error';
when /is not (defined|a function)|cannot read prop/i: 'Reference Error';
else: 'Build Error';
};
}
Today
function deriveBuildErrorType(error: unknown): string {
const text = match(error)
.when({ errors: (e: unknown[]) => e.length > 0 }, (e) => e.errors[0].text)
.when(AnError, (e) => e.message)
.else(String);
return match(text)
.when(/could not resolve|cannot find module|no such file/i, () => 'Module Resolution Error')
.when(/unexpected token|expected .* but found|unterminated/i, () => 'Syntax Error')
.when(/is not (defined|a function)|cannot read prop/i, () => 'Reference Error')
.else(() => 'Build Error');
}Two things the future version fixes that aren't cosmetic. [{ text }, ...] requires at least one message and binds it β today's msgs[0]?.text silently accepts an empty errors array and falls through to String(error). And AnError is an explicit opt-in matcher rather than a bare Error constructor, because a bare constructor pattern meaning instanceof would be wrong for precisely the reason failure.ts documents at length: a build error from a Worker or a vm context has a different Error.prototype. Constructors do not get implicit instanceof in patterns. They opt in, or they don't participate.
C. The loop β where userland genuinely cannot follow
Future
const plugins: EsbuildPlugin[] = [];
for (const raw of pluginPaths) {
const plugin = await loadPlugin(raw) else (f) { warnings.push(f); continue; };
plugins.push(plugin); // plugin: EsbuildPlugin
}
Today β and this stays a plain if, forever:
for (const raw of pluginPaths) {
const plugin = await loadPlugin(raw);
if (Failure.is(plugin)) { warnings.push(plugin); continue; }
plugins.push(plugin);
}A userland match cannot express this at any price. continue does not cross a function boundary, and neither does return or break. This is the one place where "just use a library" is not a partial answer β it's no answer. Which is also why is/else are worth more to this codebase than match is: the two-way case is the common one, and it's the case libraries can't reach.
D. The wire boundary β lib/setup/web-server.ts
match (Result.try(JSON.parse, frame)) {
when { ok: false }: log('malformed frame, dropping');
when { ok: true, value: { event: 'done', failed: 0 } }: finish(0);
when { ok: true, value: { event: 'done', failed } }: finish(failed);
when { ok: true, value: { event: 'test', name, ...rest } }: report(name, rest);
else: log(`unknown frame: ${frame.slice(0, 80)}`);
}
Result.try returns the Caught box, and the box exists because a caught unknown has no brand β so this is the one place in the system where a nested { ok, value } pattern is the right shape rather than a bare union. The match reads the reason for the box's existence right off the page.
Edge cases, with the answer
Getters run once, not once per clause.
let reads = 0;
const cfg = { get mode() { reads++; return 'watch'; } };
match (cfg) {
when { mode: 'ci' }: runCI();
when { mode: 'watch' }: runWatch(); // reads === 1
else: never();
}
The spec memoizes every Get for the duration of one match. The userland kit above does not β it reads mode twice. If your subject has getters or is a Proxy, that difference is observable today and vanishes with the syntax.
Patterns test presence; destructuring doesn't.
({ a } = {}); // a === undefined, no complaint
({} is { a }); // false β HasProperty first
({ a: undefined } is { a }); // true β present, holding undefined
Bare identifiers always bind β the Rust bug, prevented.
const EXPECTED = 200;
match (res) {
when { status: EXPECTED }: ok(); // β SyntaxError: `EXPECTED` binds; did you mean ${EXPECTED}?
when { status: ${EXPECTED} }: ok(); // β tests
}
In Rust the first form silently matches the constant, and renaming it to lowercase silently changes it to a catch-all. Here the rule has no exceptions, so the mistake is an early error instead of a lint.
Userland inverts this, and the codemod must too. In the kit above there are no bindings in patterns, so every value is pinned β { status: EXPECTED } tests. Migrating .when({ status: EXPECTED }) to syntax means writing ${EXPECTED}. That is the single most mechanical-looking, most wrong-if-skipped step in the migration.
| branches must bind the same names.
when { status: 301 | 302, headers: { location } }: redirect(location); // β
when { code: 'A', data: { path } } | { code: 'B', data: { host } }: ...; // β early error
A throw in a guard propagates.
when { data: { path } } if (fs.statSync(path).isFile()): use(path); // ENOENT escapes the match Elixir would swallow this and try the next clause. That behavior is coherent there because guards are a pure whitelist; here it would mean a bug in a guard silently reroutes control flow, which is the exact two-tier violation the whole system exists to prevent. Guards may lie about matching. They may not eat bugs.
An irrefutable pattern is only legal in else.
match (result) {
when other: log(other); // β SyntaxError: irrefutable pattern β use `else (other):`
when Failure(f): report(f); // (unreachable)
}
This is what makes "does this match have an else?" a complete answer to "is it total?"
NaN matches; switch still won't.
match (duration) { when NaN: 'timer never started'; else (ms): `${ms}ms`; }
switch (duration) { case NaN: /* dead code, forever */ }
Literal patterns use SameValueZero β so -0 matches 0, consistent with Map, Set, and includes.
Strings are iterable and array patterns still reject them.
'ci' is [a, b]; // false β deliberate
'ci' is /^(ci|watch)$/; // true β string structure goes through regex patterns
Iterators are drawn once, and only as far as needed.
match (linesFrom(hugeFile)) { // a generator
when []: 'empty';
when [only]: only; // draws 2 to prove length 1, caches both
when [first, ...]: first; // reuses the cached draws β no re-iteration
else: unreachable();
}
when [a, ...rest] against an infinite generator hangs, exactly as [...gen] does. That's user error, not a spec hole.
Custom matchers: false is no-match, [] is match-with-nothing-extracted, anything else is a TypeError.
const Even = { [Symbol.customMatcher]: (n) => typeof n === 'number' && n % 2 === 0 && [] };
const Split = { [Symbol.customMatcher]: (s) => typeof s === 'string' ? s.split(':') : false };
match (arg) {
when Even: 'even number';
when Split(host, port): connect(host, Number(port));
else: usage();
}
Returning undefined from a matcher throws rather than being read as no-match β a matcher that forgets to return is a bug, and bugs travel as exceptions.
Migration, line by line
ββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Userland today β Syntax later β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β .when({ code: 'X' }, (f) => β¦) β when { code: 'X' }: β¦ β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β .when(p, guard, handler) β when p if (guard): β¦ β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β .when({ status: EXPECTED }) β when { status: ${EXPECTED} } β the one that bites β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β .when(/re/, β¦) β when /re/: β¦ β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β .when(Failure.is, β¦) β when Failure(f): β¦ β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β _ β _ β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β pin(fn) β ${fn} β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β .else(h) β else (v): β¦ β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β .exhaustive() β omit else β MatchError at runtime, compile error in TS β
ββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β if (Failure.is(x)) { β¦ continue; } β const v = e else (f) { β¦ continue; } β
ββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Everything above the last row is a codemod. The last row is the part you can only get from the language, which is the whole argument for is and else going to committee before match does.