asPipes is an experimental runtime abstraction that models the semantics of the proposed |> pipeline operator, implemented entirely in standard JavaScript (ES2020+).
It demonstrates that pipeline-style composition can be expressed using the existing coercion semantics of the bitwise OR operator (|) and Symbol.toPrimitive.
The implementation is small (<50 lines) and supports both synchronous and asynchronous evaluation with a familiar syntax:
The pipeline operator proposal (tc39/proposal-pipeline-operator) has been under discussion for several years, exploring multiple variants (F#, Smart, Hack, etc.).
The asPipes experiment aims to:
prototype F#-style semantics directly in today’s JavaScript;
study ergonomics and readability in real-world code;
show that deferred, referentially transparent composition can be achieved without syntax extensions; and
inform the design conversation with practical, user-level feedback.
⸻
✅ Composable — each transformation behaves like a unary function of the previous result.
✅ Deferred — no execution until .run() is called.
✅ Async-safe — promises and async functions are first-class citizens.
✅ Stateless — no global mutation; every pipeline owns its own context.
✅ Ergonomic — visually aligns with the future |> operator.
⸻
createAsPipes()
Creates an isolated pipeline environment and returns:
{pipe,// begin a pipelineasPipe// lift a function into a pipeable form}
pipe(initialValue)
Begins a new pipeline with initialValue.
The returned object intercepts | operations via Symbol.toPrimitive.
Call .run() to evaluate and retrieve the final result (async).
asPipe(fn)
Wraps a function fn so that it can be used in a pipeline:
Pipeable functions can also be called with arguments:
pipe('hello')|upper|ex('!!!')
.run()
Evaluates the accumulated transformations sequentially, returning a Promise of the final value.
⸻
5 Reference Implementation
exportfunctioncreateAsPipes(){conststack=[]constasPipe=(fn)=>newProxy(function(){},{get(_,prop){if(prop===Symbol.toPrimitive)return()=>(stack.at(-1).steps.push(async(v)=>{conststackLengthBefore=stack.lengthconstresult=awaitPromise.resolve(fn(v))// If a new pipeline was created during fn execution and result is 0if(result===0&&stack.length>stackLengthBefore){// Get the pipeline that was created and execute itconstpipelineCtx=stack[stack.length-1]stack.pop()// Remove from stack as we're executing itreturnawaitpipelineCtx.steps.reduce((p,f)=>p.then(f),Promise.resolve(pipelineCtx.v))}// If the function returns a pipeline token, execute it automaticallyif(result&&typeofresult.run==='function'){returnawaitresult.run()}returnresult}),0)},apply(_,__,args){constt=function(){}t[Symbol.toPrimitive]=()=>(stack.at(-1).steps.push(async(v)=>{conststackLengthBefore=stack.lengthconstresult=awaitPromise.resolve(fn(v, ...args))// If a new pipeline was created during fn execution and result is 0if(result===0&&stack.length>stackLengthBefore){// Get the pipeline that was created and execute itconstpipelineCtx=stack[stack.length-1]stack.pop()// Remove from stack as we're executing itreturnawaitpipelineCtx.steps.reduce((p,f)=>p.then(f),Promise.resolve(pipelineCtx.v))}// If the function returns a pipeline token, execute it automaticallyif(result&&typeofresult.run==='function'){returnawaitresult.run()}returnresult}),0)returnt}})constpipe=(x)=>{constctx={v: x,steps: []}consttoken={[Symbol.toPrimitive]: ()=>(stack.push(ctx),0),asyncrun(){returnctx.steps.reduce((p,f)=>p.then(f),Promise.resolve(ctx.v))}};returntoken};return{ pipe, asPipe }}
Pipes can be composed into reusable, named higher-order pipes by wrapping them with asPipe. The implementation automatically detects and executes pipeline expressions, enabling clean, direct syntax:
const{ pipe, asPipe }=createAsPipes()// Assume postJson, toJson, pick, trim are defined (see example C)// Create reusable bot operationsconstaskBot=asPipe((question)=>pipe('https://api.berget.ai/v1/chat/completions')|postJson({model: 'gpt-oss',messages: [{role: 'user',content: question}]})|toJson|pick('choices',0,'message','content')|trim)constsummarize=asPipe((text)=>pipe('https://api.berget.ai/v1/chat/completions')|postJson({model: 'gpt-oss',messages: [{role: 'system',content: 'Summarize in one sentence.'},{role: 'user',content: text}]})|toJson|pick('choices',0,'message','content')|trim)// Compose an agent that chains multiple bot operationsconstresearchAgent=asPipe((topic)=>pipe(`Research topic: ${topic}`)|askBot|summarize)// Use the composed agent in a pipelineletresult;(result=pipe('quantum computing'))|researchAgentconsole.log(awaitresult.run())// First asks bot about quantum computing, then summarizes the response
This pattern demonstrates:
Composability: Small pipes (askBot, summarize) combine into larger ones (researchAgent)
reduce(iterable, reducer, initial) - Reduce stream to a single value
These functions work seamlessly with async generators, enabling reactive patterns like waiting for specific events in an endless stream.
⸻
Each pipe() call creates a private evaluation context { v, steps[] }.
Every pipeable function registers a transformation when coerced by |.
.run() folds the step list into a promise chain:
value₀ → step₁(value₀) → step₂(value₁) → … → result
Each step may return either a value or a promise.
Evaluation order is strict left-to-right, with promise resolution between steps.
⸻
8 Motivation and Design Notes
Why use Symbol.toPrimitive?
Because bitwise operators force primitive coercion and can be intercepted per-object, giving a hook for sequencing without syntax modification.
Why | and not || or &?
| is the simplest binary operator that (a) performs coercion on both operands, and (b) yields a valid runtime expression chain.
Why explicit .run()?
It makes side effects explicit, keeps the evaluation lazy, and aligns with functional semantics (like Observable.subscribe() or Task.run()).
Limitations:
Doesn’t support arbitrary expressions on the right-hand side (only pipeable tokens).
Overuse may confuse tooling or linters.
Purely demonstrative — not intended for production.
⸻
Could a future ECMAScript grammar support a similar deferred evaluation model natively?
What would static analyzers and TypeScript need to infer such pipeline types?
Can the |> proposal benefit from runtime experiments like this to clarify ergonomics?
Should .run() be implicit (auto-executed) or always explicit?
⸻
asPipes is not a syntax proposal but a runtime prototype — a living example of how far JavaScript can stretch to approximate future language constructs using only what’s already standardized.
It demonstrates that:
The semantics of pipelines are composable and ergonomic in practice.
Async behavior integrates naturally.
The readability and cognitive flow of |> syntax can be validated today.