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.
npm install ppipeimport 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); // 3Chain functions together, passing the result of each to the next:
ppipe('hello')
.pipe(s => s.toUpperCase())
.pipe(s => s + '!')
.value; // 'HELLO!'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;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));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}`); // stringCreate 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 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'Creates a new pipe with the given initial value.
const pipe = ppipe(initialValue);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)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.
Standard Promise then interface. Always available for consistent async handling.
Standard Promise catch interface. Always available for consistent async handling.
The placeholder symbol for argument positioning.
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 pipeGeneric 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 argsVersion 3.0 is a TypeScript rewrite that prioritizes type safety. Some dynamic features that couldn't be strictly typed have been removed:
| 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) |
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
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 numberFunctions 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.
- 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
| 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, throwsThe 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; // ✓ numberThis 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"
);100% test coverage is maintained. To run tests:
npm install
npm testSee CONTRIBUTING.
See CHANGELOG.md for version history.
ISC
