npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pure-effect

v0.8.0

Published

A zero-dependency effect library for JavaScript and TypeScript with built-in support for dependency injection, retry, and OpenTelemetry where business logic is plain data you can test without mocks.

Readme

Pure Effect

npm version bundle size license

Pure Effect is a zero-dependency effect library for JavaScript and TypeScript with built-in support for dependency injection, retry, and OpenTelemetry where business logic is plain data you can test without mocks.

  • No mocks needed to test async pipelines
  • Inject context without touching function signatures
  • Built-in retry and parallel execution with configurable delay, backoff, and Promise.all semantics
  • OpenTelemetry-ready via lifecycle hooks
  • Zero dependencies, under 1 KB minified and gzipped
  • Works in JavaScript and TypeScript (full generics, bundled .d.ts)
  • Six primitives: learn the whole API in an afternoon

Table of Contents

Installation

npm install pure-effect

Quick Start

Here is a complete example of a user registration flow:

import { Success, Failure, Command, effectPipe, runEffect } from 'pure-effect';

const validateRegistration = (input) => {
    if (!input.email.includes('@')) return Failure('Invalid email.');
    if (input.password.length < 8) return Failure('Password too short.');
    return Success(input);
};

// These functions return a Command object. They do NOT call the database.
const findUser = (email) => {
    const cmdFindUser = () => db.findUser(email);
    return Command(cmdFindUser, (user) => Success(user));
};

const saveUser = (input) => {
    const cmdSaveUser = () => db.saveUser(input);
    return Command(cmdSaveUser, (saved) => Success(saved));
};

const ensureEmailAvailable = (user) => (user ? Failure('Email already in use.') : Success(true));

// effectPipe threads the output of each step into the next.
// When you need to use a captured variable instead of the piped value,
// wrap the call in an arrow function.
const registerUserFlow = (input) =>
    effectPipe(
        validateRegistration, // input -> Success(input)
        () => findUser(input.email), // ignores piped value, uses captured input
        ensureEmailAvailable, // found user (or null) -> Success(true)
        () => saveUser(input) // ignores piped value, uses captured input
    )(input);

// Imperative shell: this is the only place side effects run
async function registerUser(input) {
    const result = await runEffect(registerUserFlow(input));

    if (result.type === 'Success') {
        console.log('User created:', result.value);
    } else {
        console.error('Error:', result.error);
    }
}

Testing Without Mocks

Because pipelines return plain objects, you can assert on what the code intends to do without executing any of it. Using the registration flow defined in the previous section:

// 1. Test validation failure synchronously
const badInput = { email: 'bad-email', password: '123' };
const result = registerUserFlow(badInput);

assert.deepEqual(result, Failure('Invalid email.', badInput));

// 2. Walk the pipeline to verify intent
const goodInput = { email: '[email protected]', password: 'password123' };
const step1 = registerUserFlow(goodInput);

// First thing the code does: look up the user
assert.equal(step1.type, 'Command');
assert.equal(step1.cmd.name, 'cmdFindUser');

// Next thing: save the user (simulate "user not found" result)
const step2 = step1.next(null);
assert.equal(step2.type, 'Command');
assert.equal(step2.cmd.name, 'cmdSaveUser');
// The full flow is verified. The database was never touched.

Passing Runtime Context

Some values come from the framework layer such as an authenticated tenant, a request trace ID, an environment config rather than from the data being processed. Ask lets a pipeline step read the context object passed to runEffect without threading it through every function signature.

In the example below, ctx.tenant is resolved from the JWT by the router. The domain layer never receives it as a parameter; it just asks for it when needed:

import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';

const findProduct = (productId) =>
    Ask((ctx) => {
        const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
        return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
    });

const reserveStock = (product) =>
    Ask((ctx) => {
        const cmdReserveStock = () => db[ctx.tenant].reserveStock(product.id);
        return Command(cmdReserveStock, (reserved) => Success({ product, reserved }));
    });

const checkoutFlow = (productId) => effectPipe(findProduct, ({ product }) => reserveStock(product))(productId);

app.post('/checkout', async (req, res) => {
    const result = await runEffect(checkoutFlow(req.body.productId), {
        tenant: req.tenant
    });
    res.json(result);
});

Retrying Transient Failures

Retry wraps any Effect tree with retry-on-failure semantics. Like everything else in Pure Effect, the retry configuration is a plain object you can inspect and assert on without running anything.

import { Success, Failure, Command, Retry, effectPipe, runEffect } from 'pure-effect';

const fetchWeather = (city) => {
    const cmdFetchWeather = () =>
        fetch(`https://example-weather-api.com/v1/current?city=${city}`).then((r) => r.json());
    return Retry(
        Command(cmdFetchWeather, (data) => (data.error ? Failure(data.error) : Success(data))),
        { attempts: 3, delay: 200, backoff: 2 } // 200ms, 400ms, 800ms
    );
};

// Assert on the retry config without making any network calls
const weatherFn = fetchWeather('Tokyo');
assert.equal(weatherFn.type, 'Retry');
assert.equal(weatherFn.options.attempts, 3);
assert.equal(weatherFn.effect.type, 'Command');

When all attempts are exhausted, runEffect returns a structured Failure:

{
    retryExhausted: true,
    lastError: <the last error>,
    attempts: 3
}

Global defaults can be set via configureEffect and overridden per-use:

configureEffect({
    retry: { attempts: 3, delay: 100, backoff: 1 } // flat delay by default
});

// Per-use options are merged on top of global defaults
Retry(effect, { delay: 500 }); // uses global attempts, custom delay

Running Effects in Parallel

Parallel runs multiple Effect trees concurrently and passes their results to next as an ordered array. If any effect fails, next is not called and the Failure propagates immediately.

import { Success, Failure, Command, Parallel, effectPipe, runEffect } from 'pure-effect';

const getUser = (id) => {
    const cmdGetUser = () => db.users.findById(id);
    return Command(cmdGetUser, (user) => (user ? Success(user) : Failure('user_not_found')));
};

const getPermissions = (id) => {
    const cmdGetPermissions = () => db.permissions.findByUserId(id);
    return Command(cmdGetPermissions, (perms) => Success(perms));
};

const loadProfile = (userId) =>
    Parallel([getUser(userId), getPermissions(userId)], ([user, permissions]) => Success({ user, permissions }));

Because Parallel is a plain object, you can assert on its structure without running anything:

const flow = loadProfile('user-123');
assert.equal(flow.type, 'Parallel');
assert.equal(flow.effects.length, 2);
assert.equal(flow.effects[0].type, 'Command');
assert.equal(flow.effects[0].cmd.name, 'cmdGetUser');

Parallel composes naturally inside effectPipe:

const checkoutFlow = (input) =>
    effectPipe(
        validate,
        ({ productId, userId }) =>
            Parallel([getProduct(productId), getUser(userId)], ([product, user]) => Success({ product, user })),
        ({ product, user }) => reserveStock(product, user)
    )(input);

Ask context flows into all parallel branches without any extra wiring.

TypeScript: Typed Errors and Context

Error union across pipeline steps

Each step in effectPipe carries its own error type. The compiler collects them into a union automatically, so the return type of runEffect tells you exactly which errors are possible with full exhaustive narrowing, no extra boilerplate.

import { effectPipe, runEffect, Failure, Success, Command } from 'pure-effect';
import type { Effect } from 'pure-effect';

type ValidationError = 'invalid_email' | 'weak_password';
type ApiError = 'network_timeout' | 'rate_limited';

const validate = (input: { email: string }): Effect<{ email: string }, ValidationError> => {
    if (!input.email.includes('@')) return Failure('invalid_email');
    return Success(input);
};

const submit = (input: { email: string }): Effect<{ id: number }, ApiError> => {
    const cmdSubmitUser = () =>
        fetch('/api/users', { method: 'POST', body: JSON.stringify(input) }).then((r) => r.json());
    return Command(cmdSubmitUser, (data) => Success(data));
};

const flow = effectPipe(validate, submit);
const result = await runEffect(flow({ email: '[email protected]' }));
// result: SuccessState<{ id: number }> | FailureState<ValidationError | ApiError>

if (result.type === 'Failure') {
    result.error; // 'invalid_email' | 'weak_password' | 'network_timeout' | 'rate_limited'
}

Typed context with Ask

Effect<T, E, Ctx> carries a third type parameter for the context object. TypeScript enforces that runEffect receives a matching value when you annotate Ask with a context type:

import { Ask, Command, Success, Failure, effectPipe, runEffect } from 'pure-effect';
import type { Effect } from 'pure-effect';

type AppContext = { tenant: string; requestId: string };

const findProduct = (productId: string): Effect<Product, 'not_found', AppContext> =>
    Ask<Product, 'not_found', AppContext>((ctx) => {
        const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
        return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('not_found')));
    });

// ctx is typed as AppContext, no cast needed
const result = await runEffect(findProduct('abc'), { tenant: 'acme', requestId: '123' });

Why Pure Effect

vs. Effect-TS: Effect-TS is a full functional programming ecosystem with fibers, streaming, schema validation, structured concurrency, and more, though it comes with a steep learning curve. Pure Effect covers a narrower scope: testable pipelines, context injection, retry, and parallel execution. If your problem is testability and async pipeline boilerplate, Pure Effect solves it with just six primitives you can learn in an afternoon. If you need fibers, in-flight cancellation, or streaming, Effect-TS is the right tool.

vs. fp-ts: fp-ts brings category theory abstractions (functors, monads, applicatives) to TypeScript. Pure Effect borrows only the concept of effects as data, without that vocabulary.

vs. plain async/await with mocks: A mock that passes all your tests but diverges from what the real database driver or HTTP client actually does is worse than no test at all; it gives false confidence. Pure Effect eliminates the problem at the source: business logic never executes I/O, so there is nothing to mock. You assert on what the code intends to do, not on a substitute that approximates it.

When to use something else: If your codebase has little async I/O or test isolation isn't a pain point, plain async/await is the simpler choice.

API Reference

Success(value)

Returns { type: 'Success', value }. Represents a successful computation result.

Failure(error, initialInput?)

Returns { type: 'Failure', error, initialInput }. Stops the pipeline immediately and short-circuits any remaining steps.

Command(cmdFn, nextFn, meta?)

Returns { type: 'Command', cmd, next, meta }.

  • cmdFn: A function (sync or async) that performs the side effect.
  • nextFn: Receives the result of cmdFn and returns the next Effect.
  • meta: Optional metadata (available to onBeforeCommand).

Ask(nextFn)

Returns { type: 'Ask', next }. Passes the context from runEffect into nextFn, which returns the next Effect. Works at any point in a pipeline.

const findProduct = (productId) =>
    Ask((ctx) => {
        const cmdFindProduct = () => db[ctx.tenant].findProduct(productId);
        return Command(cmdFindProduct, (product) => (product ? Success(product) : Failure('Product not found.')));
    });

Retry(effect, options?)

Returns { type: 'Retry', effect, options, next }. Wraps any Effect with retry-on-failure semantics.

  • effect: Any Effect: a Command, an effectPipe result, or another Retry.
  • options.attempts: Max retries, not counting the first try (default: 3).
  • options.delay: Ms before the first retry (default: 100).
  • options.backoff: Multiplier applied to delay on each attempt (default: 1, flat).

On exhaustion, returns Failure({ retryExhausted: true, lastError, attempts }).

Parallel(effects, next)

Returns { type: 'Parallel', effects, next }. Runs all effects concurrently via Promise.all. If any effect fails, the first Failure is returned immediately and next is not called. Ask context flows into all branches.

  • effects: Array of any Effects such as Commands, effectPipe results, nested Retrys, etc.
  • next: Receives the array of unwrapped success values in the same order as effects, returns the next Effect.

effectPipe(...functions)

Composes functions into a sequential pipeline. Each function receives the unwrapped Success value from the previous step. A Failure from any step stops the pipeline immediately.

When a step needs to use a captured variable instead of the piped value, wrap it in an arrow function:

const flow = (input) =>
    effectPipe(
        validate, // receives input
        () => findUser(input.email), // ignores piped value, uses captured input
        ensureAvailable,
        () => saveUser(input) // ignores piped value, uses captured input
    )(input);

runEffect(effect, context?, callConfig?)

The interpreter. Traverses the effect tree, executes Commands with async/await, resolves Ask with the supplied context, and returns the final Success or Failure.

  • context: Optional object passed to Ask continuations and onBeforeCommand. context.flowName names the workflow in telemetry.
  • callConfig: Per-call overrides for onStep, onRun, onBeforeCommand, and retry. Takes precedence over configureEffect globals.
  • onRun fires exactly once per runEffect call. Retry attempts run inside that single span. The interpreter does not re-enter runEffect per attempt.

configureEffect(options)

  • onRun(effect, pipeline, flowName) wraps the entire workflow; must await pipeline().
  • onStep(name, type, op) wraps each Command; must await op() and return its result.
  • onBeforeCommand(command, context) fires before each Command; throw to abort the pipeline.
  • retry: { attempts?, delay?, backoff? } global retry defaults.

See opentelemetry-example.js in the repository for a complete OpenTelemetry wiring example.