@jesscss/awaitable-pipe
A tiny, strongly-typed pipe that stays synchronous until a step returns a Promise — with one optional error handler.
It’s a small utility from the Jess project,
where hot paths call functions that are usually — but not always —
synchronous. It’s zero-dependency and usable on its own: it stays sync when
everything is sync, and turns into a Promise only when something is async. No
wrappers, no ceremony.
- Stays sync when it can: all-sync pipelines return a plain value
- Goes async when it must: any async input/step returns a Promise
- One place for errors:
safePipe gives you a single onError + optional fallback
- Steps-only API: start with an initializer step (() => value | Promise), or omit it entirely
- Typed nicely: TypeScript keeps the sync/async shape without Result-like wrappers
Install
npm install @jesscss/awaitable-pipe
Published to npm under both the latest and alpha dist-tags.
Quick Start
import { pipe, safePipe } from '@jesscss/awaitable-pipe';
const upper = (s: string) => s.toUpperCase();
const exclaim = (s: string) => s + '!';
const out = pipe(() => 'ok', upper, exclaim);
const load = async (s: string) => s + '!';
const outP = pipe(() => 'ok', upper, load);
const result = await outP;
const s2 = pipe((x?: number) => (x ?? 2) * 3);
const boom = () => { throw new Error('nope'); };
const safe = safePipe({ onError: console.error, fallback: 'X' }, () => 'ok', boom, upper);
Why would you want this?
JavaScript Promises are great, but they aren’t free. Every async hop schedules work, allocates objects, and pushes errors across an async boundary. In hot paths, that overhead can add up.
That said, consider this a micro-optimization! This is really only a faster approach than forced async / await when a very small percentage of unknown function calls result in Promises (< 10% of steps returning Promises in performance testing). If a greater percentage of steps would normally return promises, then using an async / await pattern on all unknown results (regardless of whether or not those results are a Promise, which JavaScript is fine with) will generally be faster than using this library.
Features
- Zero extra overhead for sync work: when your steps are synchronous, you get plain values—no microtasks, no
await, no extra Promise allocations.
- Seamless async when you need it: if any step is async, the pipeline naturally promotes to a Promise—no special handling required.
- Cleaner stacks: sync-only flows keep straightforward stack traces and easier debugging.
- Simple error strategy: prefer natural throw/reject with
pipe, or centralize it once with safePipe without wrapping results.
API
pipe(...steps)
- Return shape: sync returns a value; any async → Promise
- Errors: sync errors throw; async errors reject
- Inputs: value, Promise, thunk (() => value|Promise), or omit (first step gets
undefined)
const a = pipe(() => 'hi', (s) => s.trim(), (s) => s.toUpperCase());
const b = pipe(() => 'hi', async (s) => s + '!', (s) => s + '?');
const c = pipe((x?: number) => (x ?? 1) + 1, (n) => n * 10);
safePipe(options, ...steps)
If you prefer not to throw or reject, safePipe centralizes error handling. You get an optional onError callback and a fallback value (or thunk). On error, the pipeline returns the fallback (or undefined if you didn’t provide one).
- Never throws: errors are caught and routed to
onError
- Return shape preserved: still sync-if-sync, async-if-async
- Start forms: options-first (no explicit initial value) or steps-only
const r1 = safePipe({ onError: console.warn, fallback: 'X' }, () => 'ok', (s: string) => s.toUpperCase());
const r2 = safePipe({ onError: console.warn, fallback: 'X' },
() => { throw new Error('boom'); },
(s: string) => s.toUpperCase()
);
const r3 = await safePipe({ onError: console.warn, fallback: 'X' },
() => 'ok',
async (s: string) => s + '!',
(s: string) => s + '?'
);
const r5 = safePipe({ onError: console.warn },
() => { throw new Error('boom'); },
(s: string) => s.toUpperCase()
);
Composing pipes
You can feed the output of one pipe (value or Promise) into another. Types keep up with you.
const p1 = pipe(() => 'hi', (s: string) => s.toUpperCase());
const p2 = pipe(() => p1, (s) => s + '!');
const p3 = pipe(() => 'hi', async s => s + '!');
const p4 = pipe(() => p3, (s) => s + '?');
const fin = await p4;
Per-step helpers
Sometimes you want to guard or handle errors at a specific step without switching the whole pipeline to safe mode. Use these helpers as steps inside pipe or safePipe:
import { pipe, tryStep, guard, serialForEach, serialReduce } from '@jesscss/awaitable-pipe';
const step = tryStep((n: number) => {
if (n < 0) throw new Error('no negatives');
return n * 2;
}, {
onError: (err, n) => console.warn('bad number:', n, err),
fallback: 0
});
const out = pipe(() => 5, step);
const out2 = pipe(() => -1, step);
const stepWithRethrow = tryStep((input: string) => {
if (input === 'bad') throw new ReferenceError('not found');
return input.toUpperCase();
}, {
onError: (error, input) => {
if (error instanceof ReferenceError) {
throw error;
}
console.log('Handled error:', error);
},
fallback: 'default'
});
const result1 = pipe(() => 'good', stepWithRethrow);
const result2 = pipe(() => 'bad', stepWithRethrow);
const result3 = pipe(() => 'other', stepWithRethrow);
const alwaysRethrow = tryStep((n: number) => {
if (n < 0) throw new Error('no negatives');
return n * 2;
}, {
onError: (err, n) => console.warn('Error:', err),
rethrow: true
});
const positive = guard((n: number) => n > 0, (n) => new Error(`not positive: ${n}`));
const ok = pipe(() => 3, positive);
const items = [1, 2, 3];
await serialForEach(items, async (n, i) => {
if (i === 1) await new Promise(r => setTimeout(r, 10));
});
const sum = await serialReduce(items, 0, async (acc, n, i) => {
if (i === 2) await Promise.resolve();
return acc + n;
});
Status
Alpha, as part of the Jess monorepo. Please
report issues.
Links
License
MIT