Skip to content

Repository files navigation

PPIPE

build Coverage Status npm npm license

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 any types 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 .value type - Resolves to T or Promise<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 any in 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 test

Contributing

See CONTRIBUTING.

Changelog

See CHANGELOG.md for version history.

License

ISC

About

pipes values through functions, an alternative to using the proposed pipe operator ( |> ) for ES

Topics

Resources

Code of conduct

Contributing

Stars

193 stars

Watchers

4 watching

Forks

Releases

Packages

Used by

Contributors

Languages