Strictly-typed pipes for values through functions, an alternative to using the proposed pipe operator ( |> ) for ES.
Version 3.0 is a complete TypeScript rewrite with maximum type safety - no any in the public API, full IDE autocomplete support, and correct type inference throughout the chain.
Installation
npm install ppipe
Quick Start
import ppipe, { _ } from 'ppipe'; const add = (x: number, y: number) => x + y; const square = (x: number) => x * x; const divide = (x: number, y: number) => x / y; const double = (x: number) => x * 2; // Basic piping const result = ppipe(1) .pipe(add, _, 1) // 2 .pipe(double) // 4 .pipe(square) // 16 .pipe(divide, _, 8) // 2 .pipe(add, _, 1) // 3 .value; console.log(result); // 3
Features
Basic Piping
Chain functions together, passing the result of each to the next:
ppipe('hello') .pipe(s => s.toUpperCase()) .pipe(s => s + '!') .value; // 'HELLO!'
Placeholder Positioning
Use _ to control where the piped value is inserted:
const _ = ppipe._; // Value inserted at placeholder position ppipe(10) .pipe(divide, _, 2) // divide(10, 2) = 5 .value; // Without placeholder, value is appended at the end ppipe(10) .pipe(divide, 100) // divide(100, 10) = 10 .value; // Multiple placeholders insert the same value multiple times ppipe(5) .pipe((a, b) => a + b, _, _) // 5 + 5 = 10 .value;
Async/Promise Support
Promises are automatically handled - the chain waits for resolution and passes the unwrapped value to the next function:
async function fetchUser(id: number) { const response = await fetch(`/api/users/${id}`); return response.json(); } const userName = await ppipe(1) .pipe(fetchUser) .pipe(user => user.name) .pipe(name => name.toUpperCase()); // Or use .then()/.catch() ppipe(1) .pipe(fetchUser) .pipe(user => user.name) .then(name => console.log(name)) .catch(err => console.error(err));
Value Extraction
Get the current value with .value (or .val):
// Sync value - typed as number, not number | Promise<number> const num = ppipe(5).pipe(x => x * 2).value; // 10 // Async value (returns Promise) - typed as Promise<number> const asyncNum = await ppipe(Promise.resolve(5)).pipe(x => x * 2).value;
.value follows the chain's tracked async state, so it resolves to an exact
type and never needs a cast or a narrowing check:
const a = ppipe(5).pipe(x => x * 2).value; // number const b = ppipe(5).pipe(async x => `${x}`).value; // Promise<string> // Awaiting the pipe itself works too, and always unwraps const c = await ppipe(5).pipe(async x => `${x}`); // string
Typed Extensions
Create reusable pipe extensions with full type inference:
const mathPipe = ppipe.extend({ double: (x: number) => x * 2, square: (x: number) => x * x, add: (x: number, y: number) => x + y, }); const result = mathPipe(5) .double() // 10 - return type inferred as number .square() // 100 .add(5) // 105 .value; // Extensions can be chained const extendedPipe = mathPipe.extend({ stringify: (x: number) => String(x), }); const str = extendedPipe(5) .double() .stringify() // '10' - return type inferred as string .value;
Generic Pass-Through Extensions
Generic identity functions like log or tap preserve the pipe's type automatically:
const pp = ppipe.extend({ log: <T>(value: T, label?: string): T => { console.log(label ?? 'value:', value); return value; }, }); // Type is preserved through .log() - no type loss! pp(8) .log('start') // logs: "start: 8" .pipe(x => x + 3) // x is number, not unknown .log('end') // logs: "end: 11" .value; // 11 pp('hello') .log() .pipe(s => s.toUpperCase()) // s is string .value; // 'HELLO'
API Reference
ppipe(value)
Creates a new pipe with the given initial value.
const pipe = ppipe(initialValue);
.pipe(fn, ...args)
Pipes the current value through a function. The value is inserted at the placeholder position, or appended at the end if no placeholder is used.
pipe.pipe(fn) // fn(value) pipe.pipe(fn, _, arg2) // fn(value, arg2) pipe.pipe(fn, arg1) // fn(arg1, value) pipe.pipe(fn, arg1, _) // fn(arg1, value)
.value / .val
Gets the current value from the chain. Typed as T for a sync chain and Promise<T> if any
function in the chain was async — the compiler knows which, so no cast is needed.
.then(onFulfilled?, onRejected?)
Standard Promise then interface. Always available for consistent async handling.
.catch(onRejected?)
Standard Promise catch interface. Always available for consistent async handling.
ppipe._
The placeholder symbol for argument positioning.
ppipe.extend(extensions)
Creates a new ppipe factory with additional methods:
const extended = ppipe.extend({ methodName: (value, ...args) => result, });
Extension functions receive the piped value as their first argument.
An extension is only offered on pipes whose value it can accept — an extension declaring
(v: string) is not available on a pipe holding a number:
const p = ppipe.extend({ upper: (v: string) => v.toUpperCase() }); p("hi").upper(); // ✓ string pipe p(42).upper(); // ✗ Type error - `upper` is not available on a number pipe
Generic pass-through extensions such as <T>(v: T) => T accept any value and stay
available across the whole chain.
Calling .extend() with a name that already exists replaces the previous extension,
matching the runtime merge:
const base = ppipe.extend({ f: (v: number, n: number) => v + n }); const next = base.extend({ f: (v: number) => v.toString() }); next(5).f(); // string - the overriding definition, taking no extra args
Migration from v2.x
Version 3.0 is a TypeScript rewrite that prioritizes type safety. Some dynamic features that couldn't be strictly typed have been removed:
Removed Features
| Feature | v2.x | v3.x Alternative |
|---|---|---|
| Deep property access | _.a.b.c |
.pipe(x => x.a.b.c) |
| Array spreading | ..._ |
.pipe(arr => fn(...arr)) |
| Direct method access | .map(fn) |
.pipe(arr => arr.map(fn)) |
| Context binding | .with(ctx) |
.pipe(fn.bind(ctx)) |
| Callable syntax | ppipe(val)(fn) |
ppipe(val).pipe(fn) |
Why These Changes?
These features relied on Proxy magic that returned any types, breaking TypeScript's ability to infer types correctly. The v3.x API ensures:
- Full IDE autocomplete support
- Correct type inference throughout the chain
- No
anytypes in the public API - Compile-time error detection
Type Safety
ppipe v3.x provides complete type inference with arity checking - passing extra arguments to functions that don't expect them produces compile-time errors:
// Types are inferred correctly through the chain const result = ppipe(5) .pipe(x => x * 2) // Pipe<number> .pipe(x => x.toString()) // Pipe<string> .pipe(x => x.length) // Pipe<number> .value; // number // Async types are tracked const asyncResult = ppipe(Promise.resolve(5)) .pipe(x => x * 2) // Pipe<number, async=true> .value; // Promise<number> // Extension return types are inferred const myPipe = ppipe.extend({ toArray: <T>(x: T) => [x], }); myPipe(5).toArray().value; // number[] // Generic identity extensions preserve the pipe's type const debugPipe = ppipe.extend({ log: <T>(value: T): T => { console.log(value); return value; }, }); debugPipe(5).log().pipe(x => x * 2).value; // x is number, result is number
Arity Checking
Functions are checked to ensure they receive the correct number of arguments:
const subtract = (a: number, b: number) => a - b; // ✓ Correct - 2-param function with 2 args ppipe(10).pipe(subtract, _, 3).value; // 7 // ✗ Error - 2-param function with 4 args ppipe(10).pipe(subtract, _, 3, 5, 10).value; // Type error: Property 'value' does not exist on type // 'ArityMismatch<"the arguments passed to .pipe() do not match the piped function's parameters">'
Argument types are checked alongside the count, in both placeholder and trailing positions.
Type Safety Strengths
- Full inference for lambdas - Untyped lambdas get correct types for 1-4 arguments
- Arity mismatch detection - Wrong argument counts and types produce compile errors
- Exact
.valuetype - Resolves toTorPromise<T>from tracked async state; never a union you must narrow - Extension value-type checking - An extension is unavailable on pipes it cannot accept
- Extension type preservation - Generic identity extensions (like
log) preserve the pipe's type - Override-correct
extend()- Re-declaring an extension name replaces it, as the runtime does - No
anyin public API - Complete type safety throughout
Type Safety Limitations
| Scenario | Behavior |
|---|---|
| 5+ args with untyped lambda | Requires explicit type annotations |
| Arity errors | Appear on member access, not at the .pipe() call |
| Overloaded functions | Resolve against the last overload only (see below) |
| Variadic functions | Allowed through arity check (by design) |
| Zero-parameter functions | Accepted; the value is passed and ignored (by design) |
Overloaded functions — unsound, not merely imprecise. Arity and return types are derived via
Parameters<Fn> / ReturnType<Fn>, which only see the final overload signature. The inferred type
can therefore disagree with the value actually produced, so this is a genuine hole and not just a
loss of precision:
function over(a: number, b: number): number; function over(a: string, b: string): string; const s = ppipe(5).pipe(over, _, 3).value; // typed string; at runtime it is the number 5 s.toUpperCase(); // compiles, throws
The same applies to an overloaded function used as an extension. Wrap it in a lambda to pin the intended signature:
ppipe(5).pipe((a: number) => over(a, 3)).value; // ✓ number
This is a TypeScript limitation with no fix available inside the library — there is no way to recover a full overload set from a type. Prefer non-overloaded functions in pipes.
Zero-parameter functions. .pipe(fn) always calls fn with the current value, but a function
that declares no parameters is still accepted — this is standard TypeScript assignability, and it
keeps side-effecting and throwing helpers idiomatic:
ppipe(5).pipe(() => 42).value; // 42 ppipe(5).pipe(() => Promise.reject(new Error("x"))); // rejects
// 5+ args needs type annotations ppipe(1).pipe( (a: number, b: string, c: boolean, d: number, e: string) => a + d, _, "x", true, 4, "end" ).value; // ✓ Works ppipe(1).pipe( (a, b, c, d, e) => a, // ✗ 'a' will be 'never' - use typed lambda _, "x", true, 4, "end" );
Testing
100% test coverage is maintained. To run tests:
npm install
npm testContributing
See CONTRIBUTING.
Changelog
See CHANGELOG.md for version history.
License
ISC
