pure-effect v0.8.0 GitHub →

Business logic as plain data.
Tested without mocks.

Pure Effect is a zero-dependency effect library for JavaScript and TypeScript with built-in support for dependency injection, retry, and OpenTelemetry. Pipelines return plain objects you can assert against. The database is never touched until the interpreter runs them.

$ npm install pure-effect
01  /  problem

A mock that diverges from reality is worse than no test at all.

Mocking gives false confidence. Pure Effect removes the need for mocks entirely: business logic doesn't perform I/O, it returns a description of what to do. The interpreter handles execution at the boundary.

async / await + mocks

// Test stubs the dependency...
jest.mock('./db');
db.findUser.mockResolvedValue(null);
db.saveUser.mockResolvedValue({ id: 1 });

// ...and prays the stub stays in sync
// with the real driver. It rarely does.
await registerUser(input);
expect(db.saveUser).toHaveBeenCalled();

You assert against a substitute. When the substitute drifts from the real driver, tests stay green while production breaks.

pure-effect

// No mocks. The flow returns plain data.
const step1 = registerUserFlow(input);

assert.equal(step1.type, 'Command');
assert.equal(step1.cmd.name, 'cmdFindUser');

// Walk the tree without executing I/O.
const step2 = step1.next(null);
assert.equal(step2.cmd.name, 'cmdSaveUser');

You assert against the actual code path. The database is never touched. There is nothing to keep in sync.

02  /  primitives

Six pieces. Learnable in an afternoon.

Every Effect is one of these shapes. They compose into trees the interpreter walks at the edge of your system.

i.

Success(value)

OK

A successful computation result. Returns { type: 'Success', value }. Any pipeline step can return one to feed the next.

ii.

Failure(error)

ERR

Stops the pipeline immediately and short-circuits remaining steps. Optional initialInput is preserved for diagnostics.

iii.

Command(cmd, next)

deferred I/O

A side effect described as data. cmd is the function that would run; next turns its result into the next Effect.

iv.

Ask(next)

context · DI

Reads the context object passed to runEffect such as tenant, request id, and config without threading it through every signature.

v.

Retry(effect, opts)

resilience

Wraps any Effect with retry-on-failure. Configure attempts, delay, backoff. On exhaustion: a structured Failure.

vi.

Parallel(effects, next)

concurrency

Runs Effect trees concurrently via Promise.all. Ask context flows into every branch. First Failure short-circuits.

plus the composers: effectPipe(...fns) — sequential runEffect(effect, ctx?) — the interpreter configureEffect(opts) — global hooks
Complete example — user registration flow
import { Success, Failure, Command, effectPipe, runEffect } from 'pure-effect';

// Pure functions — no I/O, no imports, instantly testable.
function validateRegistration(input) {
  if (!input.email?.includes('@')) return Failure('Invalid email.');
  if (input.password?.length < 8)  return Failure('Password too short.');
  return Success(input);
}

function ensureEmailAvailable(found) {
  return found ? Failure('Email already in use.') : Success(true);
}

// Commands — side effects described as data, executed by the interpreter.
function findUserByEmail(email) {
  const cmdFindUser = () => db.findUserByEmail(email);
  return Command(cmdFindUser, (found) => Success(found));
}

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

// Pipeline — compose into a single flow.
const registerUserFlow = (input) =>
  effectPipe(
    validateRegistration,
    () => findUserByEmail(input.email),
    ensureEmailAvailable,
    () => saveUser(input)
  )(input);

// Test — assert on structure, no mocks, no I/O.
const flow = registerUserFlow({ email: '[email protected]', password: 'password123' });
assert.equal(flow.cmd.name, 'cmdFindUser');
assert.equal(flow.next(null).cmd.name, 'cmdSaveUser');

// Run — hand the tree to the interpreter at the boundary.
const saved = await runEffect(registerUserFlow(input), { flowName: 'register' });
03  /  features

What you get out of the box.

Test without mocks

Assert intent, not behavior of a substitute.

Pipelines return inert objects. Walk the tree, check what each step would do, and never touch I/O.

assert.equal(step.cmd.name, 'cmdFindUser'); assert.equal(step.type, 'Command');
Dependency injection

Context without polluting signatures.

Resolve tenant, trace id, or config from the framework layer. Domain functions stay clean — they just Ask.

Ask((ctx) => { // ctx.tenant comes from runEffect return Command(...) });
Retry as data

Transient failures handled. Retry behavior testable without waiting.

Wrap any Effect with retry semantics. Because configuration is plain data, tests assert on it directly — no timers, no sleeps.

Retry(effect, { attempts: 3, delay: 200, backoff: 2 // 200ms · 400 · 800 });
Parallel execution

Concurrent branches, ordered results.

Promise.all semantics with first-failure short-circuiting. Ask context flows into every branch automatically.

Parallel( [getUser(id), getPerms(id)], ([user, perms]) => Success({ user, perms }) );
OpenTelemetry-ready

Lifecycle hooks for tracing.

onRun, onStep, and onBeforeCommand let you wrap workflows in spans without touching domain code.

configureEffect({ onRun: (eff, pipeline, name) => tracer.startActiveSpan(name, pipeline) });
Clean domain signatures

Context reaches every function without threading it through.

Tenant IDs, request IDs, and config creep into every signature when passed explicitly. Ask reads them from the interpreter at runtime — domain code never sees them.

Ask((ctx) => Command( () => db.save(input, ctx.tenantId), (saved) => Success(saved) )); // ctx flows from runEffect — not every signature.
04  /  positioning

Where it fits in the ecosystem.

Library
DESCRIPTION
Choose pure-effect if…
Effect-TS
A full functional ecosystem with fibers, streaming, schema validation, and structured concurrency. Powerful, with a steep learning curve.
…you need testable pipelines, retry, and dependency injection that can be learned in an afternoon.
fp-ts
Brings category-theory abstractions such as functors, monads, and applicatives to TypeScript. Teaches a lot of vocabulary along with effects.
…you want effects-as-data without the academic vocabulary.
async/await + mocks
The default. Simple for small surfaces. Falls apart when test isolation matters: mocks drift from real drivers and tests turn into theater.
…test isolation is a real pain point in your codebase.
05  /  ai

AI-generated code you can verify before it runs.

Every AI code generator has the same blind spot: it produces async/await and you have to execute it to know if it's right. Pure Effect removes that blind spot. Generated pipelines are plain data. You can walk the tree, inspect each step, and confirm the structure before a single database call is made.

async / await — run to verify

// Did the AI handle the error path?
// Did it thread context correctly?
// You won't know until you run it.
async function registerUser(input) {
  const found = await db.findUser(input.email);
  if (found) throw new Error('Email in use');
  return db.saveUser(input); // awaited? who knows.
}

You audit by executing. Every verification touches infrastructure.

pure-effect — inspect to verify

// AI generated this flow. Inspect it before it runs.
const flow = registerUserFlow(input);

assert.equal(flow.type, 'Command');
assert.equal(flow.cmd.name, 'cmdFindUser');

const step2 = flow.next(null);
assert.equal(step2.cmd.name, 'cmdSaveUser');
// Correct structure confirmed. Nothing was executed.

You audit by reading the tree. No database, no network, no surprises.

06  /  install

Six primitives.
Zero dependencies.
No mocks.

$ npm install pure-effect