Typed errors, dependency injection, retries, timeouts, resources and durable execution for TypeScript, in the style of Effect, without generators.
A function's steps are written as its parameter defaults. TypeScript infers every error the function can fail with and every service it needs, with no annotations:
import { TaggedError, err, fn, match, ok, run, service } from '@folsom/defect';
import type { Result } from '@folsom/defect';
class CardDeclined extends TaggedError('CardDeclined')<{ reason: string }> {}
interface Shop {
findCart(id: string): Promise<Result<{ items: { price: number }[] }, 'cart_not_found'>>;
}
interface Payments {
charge(amount: number): Promise<Result<{ receiptId: string }, CardDeclined>>;
}
interface Mailer {
send(to: string, text: string): Promise<void>;
}
const shop = service<Shop>()('shop');
const payments = service<Payments>()('payments');
const mailer = service<Mailer>()('mailer');
const checkout = fn(
(
cartId: string,
email: string,
[cart] = shop.findCart(cartId),
total = cart.items.reduce((sum, item) => sum + item.price, 0),
[paid] = payments.charge(total),
[] = mailer.send(email, `Paid $${total}, receipt ${paid.receiptId}`),
) => ({ receipt: paid.receiptId, total }),
);
// checkout: (cartId: string, email: string) => Eff<
// { receipt: string; total: number },
// CardDeclined | "cart_not_found",
// Provides<"shop", Shop> | Provides<"payments", Payments> | Provides<"mailer", Mailer>
// >
const result = await run(checkout('cart-1', 'ada@example.com'), {
shop: { findCart: async () => ok({ items: [{ price: 12 }, { price: 3 }] }) },
payments: { charge: async () => ok({ receiptId: 'r_42' }) },
mailer: { send: async () => {} },
});
if (result.ok) console.log(result.value); // { receipt: 'r_42', total: 15 }
else
console.log(
match(result.error, {
cart_not_found: () => 'no such cart',
CardDeclined: (e) => `declined: ${e.reason}`,
}),
);run won't compile if a service is missing, misspelled or has the wrong shape.
match won't compile unless it handles every error. Add a new failure to
payments.charge and every caller that matches on it stops compiling until it
handles it.
The name: it's Effect with the generators taken out; every step lives in a parameter default; and a defect is Effect's word for a thrown, unexpected error, which is the one kind of failure this library deliberately keeps out of the types.
Status: 0.x and experimental. The approach works and is tested, but the API may change.
- Install
- How it works
- Writing steps
- API
- Results:
ok,err,isResult,unwrap,attempt - Errors:
TaggedError,match,recover - Steps:
fn,run,signal - Services:
service,need,provide,layer - Combinators:
retry,exponential,timeout,all,race,sleep - Error handling:
orElse - Resources and fibers:
acquire,fork,join - Durable execution and tracing:
durable,trace - Types
- Results:
- Limitations
- Compared with Effect
npm install @folsom/defectRequires Node 24 or later (it uses AsyncLocalStorage, AbortSignal.any and
SuppressedError). It has no dependencies, and ships ES modules with type
declarations.
Types. TypeScript writes inferred types into a function's signature in two
places: the return type, and the types of parameters that have default values.
A default can refer to the parameters before it, so a parameter list can be
used as a sequence of steps. A step has the type Eff<T, E, R>: a one-element
tuple readonly [T], plus an invisible brand recording its error type E and
the services R it needs. Destructuring it, [cart] = …, gives you the value
T. fn reads the parameter types, keeps the ones before the first step as
the function's public parameters, and unions the brands of the rest into the
function's own Eff type. That is why checkout above knows about
CardDeclined without being told.
Run time. Calling shop.findCart(id) doesn't do anything yet: it builds a
lazy task. Destructuring a value with an array pattern makes JavaScript call
its [Symbol.iterator] method, even for [] = task. That call is where the
task runs. fn calls your function once per step: on each call, the first
task that has no result yet records itself and throws, which abandons the
call. fn runs that task, stores its value by position, and calls your
function again. This time destructuring that task returns the stored value,
and the next task is reached. Steps therefore run one at a time, in order,
each exactly once, and the body runs once, after all of them. If a step fails
with an err, the function stops and returns that failure.
import { fn, ok, run, service, sleep } from '@folsom/defect';
import type { Result } from '@folsom/defect';
interface Users {
get(id: string): Promise<Result<{ name: string }, 'not_found'>>;
}
const users = service<Users>()('users');
const greet = fn(
(
id: string, // an input
greeting = 'Hello', // an optional input: a plain default before the first step
[user] = users.get(id), // a step, bound to `user`
name = user.name.toUpperCase(), // plain code, using an earlier step
[] = sleep(10), // a step whose value isn't needed
) => `${greeting}, ${name}`, // the body: runs once, after every step
);
// greet: (id: string, greeting?: string) => Eff<string, "not_found", Provides<"users", Users>>
console.log(await run(greet('1', 'Hi'), { users: { get: async () => ok({ name: 'ada' }) } }));
// Ok { ok: true, value: 'Hi, ADA' }The rules:
- Inputs are the parameters before the first
[…] = …. Plain defaults there make optional inputs. Callers can't pass anything to the step parameters, and extra arguments are dropped, soids.map(getUser)works. - A step is
[x] = stepor[] = step, wherestepis anything that returns anEff: a service method, anotherfn, or a combinator. The task can also be built elsewhere and only destructured here. - Plain defaults after a step (
nameabove) are ordinary code. They run again on every pass, so they must be cheap and free of side effects. Side effects belong in steps. - The body may return a plain value, a
Result, or another step, and may beasync. - Throwing is for bugs. A throw (a defect) is not caught: it rejects the
promise
runreturns. Expected failures are returned aserr(…).
Outside an fn, a step is a task, not its value: checkout(…).receipt is a
type error, and destructuring a step outside an fn throws. To run one, use
run.
A Result<T, E> is Ok<T> | Err<E>: { ok: true, value } or
{ ok: false, error }. Only values made by ok() and err() are Results; an
object that happens to have the same shape (such as an API reply
{ ok: false, error: 'channel_not_found' }) is an ordinary value, both in the
types and at run time.
The Results helpers are useful on their own, without steps. A plain async
function that returns ok(…) in one place and err(…) in another gets the
union as its inferred return type, and if (!r.ok) return r passes a failure
up to the caller:
import { err, ok } from '@folsom/defect';
async function parsePort(text: string) {
const n = Number(text);
if (!Number.isInteger(n)) return err('not_a_number');
if (n < 1 || n > 65535) return err('out_of_range');
return ok(n);
}
async function readPort(env: Record<string, string | undefined>) {
const raw = env['PORT'];
if (raw === undefined) return err('missing');
const port = await parsePort(raw);
if (!port.ok) return port; // the caller's type gains 'not_a_number' | 'out_of_range'
return ok(port.value);
}
const r = await readPort({ PORT: '80a' });
if (!r.ok) console.log(r.error); // 'not_a_number'; r.error: 'missing' | 'not_a_number' | 'out_of_range'Makes an Ok<T>. With no argument, an Ok<void>.
Makes an Err<E>. The error type is inferred as a literal, so err('missing')
is an Err<'missing'> and string literals work as error types. For errors
that carry data or need a stack trace, use TaggedError.
true if value was made by ok() or err().
Returns the value of an Ok, or throws the error of an Err. Meant for tests
and for places where a failure really is a bug.
Calls fn and turns a throw into an Err. If fn returns a promise, you get
a promise of a Result. onThrow sets the error: a constant, or a function of
whatever was thrown. Without it, the thrown value itself becomes the error.
import { attempt } from '@folsom/defect';
const parsed = attempt(() => JSON.parse('{oops') as unknown, 'bad_json');
console.log(parsed); // Err { ok: false, error: 'bad_json' }
const page = await attempt(
() => fetch('http://localhost:1'),
(e) => ({ name: 'Offline' as const, cause: e }),
);
console.log(page.ok); // falseErrors are told apart by name. A string literal error is its own name. An
object error is named by its name property, if that property has a literal
type, which is what TaggedError gives you. This is the same property the
platform uses (AbortError, TimeoutError), so a plain
switch (error.name) narrows correctly too.
Returns a base class for errors with a literal name and typed fields. The
instances are real Errors with stack traces. The constructor takes the
fields, plus an optional message (default: the name) and cause.
import { TaggedError } from '@folsom/defect';
class NotFound extends TaggedError('NotFound')<{ id: string }> {}
class Unavailable extends TaggedError('Unavailable') {}
const e = new NotFound({ id: '7', message: 'no user 7' });
console.log(e.name, e.id, e.message, e instanceof Error); // NotFound 7 no user 7 true
console.log(new Unavailable().message); // UnavailableCalls the handler for the error's name and returns its result. The cases must
cover every name the error type can have, and may not include any others, so
adding a failure to a function makes every match on its errors fail to
compile until the new case is handled. The return type is the union of the
handlers' return types.
import { TaggedError, match } from '@folsom/defect';
class NotFound extends TaggedError('NotFound')<{ id: string }> {}
const failure = new NotFound({ id: '7' }) as NotFound | 'rate_limited';
const status = match(failure, {
NotFound: (e) => `404: ${e.id}`,
rate_limited: () => 429,
});
console.log(status); // '404: 7'Handles some of a Result's errors by name. The handled names are removed from
the error type. A handler returns a plain value (the Result becomes an Ok
with it) or a Result (to succeed or fail differently). Errors without a
handler pass through unchanged.
import { err, recover } from '@folsom/defect';
import type { Result } from '@folsom/defect';
const found = err('not_found') as Result<string, 'not_found' | 'db_down'>;
const name = recover(found, { not_found: () => 'guest' });
// name: Ok<string> | Err<'db_down'>
console.log(name); // Ok { ok: true, value: 'guest' }For the same thing as a step, see orElse.
Turns a function whose steps are its parameter defaults (see
Writing steps) into a step function. The result takes the
inputs and returns an Eff<T, E, R>, where:
Tis what the body returns: its value, or the value of theOkor step it returns.Eis the union of every step's errors, plus anyerrthe body returns.Ris the union of every step's service requirements.
Calling it runs nothing: it builds a task, to destructure as a step in another
fn or to give to run. Every run starts from scratch.
If the function has a name, it's used in traces:
fn(function checkout(…) { … }).
Runs a step and resolves to its Result. The second argument supplies every
service the step needs, keyed by the names given to service, as objects or
layers. It's required exactly when the step needs services, and
it must be complete: a missing service, a misspelled name or the wrong shape
for an object is a type error.
The promise rejects only for defects: a thrown error, a missing service at run
time (MissingService, if the types were bypassed), or an abort.
The AbortSignal of the step that is currently running. It aborts when the
step is cancelled, for example by a timeout around it or by a sibling
failing in all. Pass it to anything that accepts a signal:
import { attempt, fn, run, signal, timeout } from '@folsom/defect';
const fetchJson = fn(async (url: string) =>
attempt(async () => (await fetch(url, { signal: signal() })).json() as Promise<unknown>, 'unreachable'),
);
const r = await run(timeout(50, fetchJson('http://10.255.255.1/'))); // an address that never answers
console.log(r.ok ? 'got it' : String(r.error)); // TimeoutError: timed out after 50msLibrary steps (service calls, sleep, the combinators) stop waiting as soon
as their signal aborts, even if the promise they wrapped never settles.
A service is an interface plus the name it is provided under. Using one of its
methods in a step adds Provides<Name, Interface> to that step's requirements.
Declares a service. (The double call is how TypeScript lets you give Api
explicitly while inferring name.) The result has the same methods as Api,
each returning a step instead of calling the real method:
- a method returning
Promise<Result<T, E>>(orResult<T, E>) becomes a step with valueTand errorE; - a method returning
Promise<T>(orT) becomes a step that can't fail (a rejected promise is a defect).
import { err, fn, ok, run, service } from '@folsom/defect';
import type { Result } from '@folsom/defect';
interface Kv {
get(key: string): Promise<Result<string, 'missing'>>;
set(key: string, value: string): Promise<void>;
}
const kv = service<Kv>()('kv');
const bump = fn(
(key: string, [current] = kv.get(key), [] = kv.set(key, String(Number(current) + 1))) => Number(current) + 1,
);
const store = new Map([['visits', '41']]);
const memory: Kv = {
get: async (key) => (store.has(key) ? ok(store.get(key)!) : err('missing')),
set: async (key, value) => void store.set(key, value),
};
console.log(await run(bump('visits'), { kv: memory })); // Ok { ok: true, value: 42 }Services are values, not global declarations, so two modules can each define a
service called store with different interfaces. It's only an error where both
are needed by the same step and no single object could satisfy both.
A step whose value is the service object itself, for when you want to call it
directly or pass it on: [clock] = need(clockService).
Supplies some of a step's services and returns a step that needs the rest. Use it to fix a dependency in one place while leaving others to the caller.
import { fn, provide, run, service } from '@folsom/defect';
const config = service<{ get(key: string): string }>()('config');
const clock = service<{ now(): number }>()('clock');
const stamp = fn((key: string, [value] = config.get(key), [now] = clock.now()) => `${value}@${now}`);
const withConfig = provide(stamp('region'), { config: { get: () => 'eu-west' } });
// withConfig needs only Provides<"clock", …>
console.log(await run(withConfig, { clock: { now: () => 1700000000 } })); // Ok { …, value: 'eu-west@1700000000' }A service built by a step, to give to run or provide instead of the service
object. The step is run once per run, before anything that uses the service,
and resources it opens with acquire stay open until that run finishes. A
layer's errors are added to the result type, and the services it needs must be
supplied too. Layers are built in the order they're listed, so a layer can use
a service listed before it.
import { acquire, fn, layer, need, run, service } from '@folsom/defect';
interface Db {
query(sql: string): Promise<string[]>;
close(): Promise<void>;
}
const config = service<{ url: string }>()('config');
const db = service<Db>()('db');
const connect = fn(
(
[cfg] = need(config),
[conn] = acquire(
async (): Promise<Db> => {
console.log(`connect ${cfg.url}`);
return { query: async () => ['row'], close: async () => console.log('disconnect') };
},
(c) => c.close(),
),
) => conn,
);
const count = fn(([rows] = db.query('select 1')) => rows.length);
console.log(await run(count(), { config: { url: 'pg://localhost' }, db: layer(connect()) }));
// connect pg://localhost
// disconnect
// Ok { ok: true, value: 1 }Each combinator takes steps and returns a step, so they nest and can be
destructured in an fn like any other step. Where they accept a step they also
accept a function of the signal, (signal) => …, returning a value, a Result,
a promise of either, or a step.
Runs step and, if it fails with an err, runs it again. Throws are never
retried. options is a number of retries or an object:
times: how many times to retry after the first failure.delay: milliseconds to wait before each retry, as a number or as a function(attempt, error) => ms(attemptcounts from 0).while: a predicate on the error; returnfalseto stop retrying.
import { TaggedError, err, exponential, fn, ok, retry, run } from '@folsom/defect';
class Busy extends TaggedError('Busy') {}
let calls = 0;
const flaky = fn(async () => (++calls < 3 ? err(new Busy()) : ok(calls)));
const r = await run(retry({ times: 5, delay: exponential(10), while: (e) => e.name === 'Busy' }, flaky()));
console.log(r); // Ok { ok: true, value: 3 }A delay function for retry: base milliseconds, multiplied by factor
(default 2) for each later attempt, capped at max (default none), with up to
jitter (0 to 1, default 0) of each delay removed at random.
exponential(100, { max: 1000 }) gives 100, 200, 400, 800, 1000, 1000, …
Fails with a TimeoutError (a TaggedError with an ms field) if step
hasn't finished after ms milliseconds. The step is aborted, and the timeout
waits for it to finish cleaning up (running its acquire finalizers) before it
returns. A step that wraps a promise which ignores the signal is abandoned
rather than waited for.
Runs steps concurrently and collects their values, in an array or an object of
the same shape as steps. If any step fails, the others are aborted and all
fails with that error. options.concurrency limits how many run at once.
import { all, err, fn, ok, run, service } from '@folsom/defect';
import type { Result } from '@folsom/defect';
const prices = service<{ of(sku: string): Promise<Result<number, 'unknown_sku'>> }>()('prices');
const total = fn((skus: string[], [each] = all(skus.map((s) => prices.of(s)), { concurrency: 4 })) =>
each.reduce((a, b) => a + b, 0),
);
const table: Record<string, number> = { a: 5, b: 7 };
const impl = { of: async (sku: string) => (sku in table ? ok(table[sku]!) : err('unknown_sku')) };
console.log(await run(total(['a', 'b']), { prices: impl })); // Ok { ok: true, value: 12 }
console.log(await run(total(['a', 'x']), { prices: impl })); // Err { ok: false, error: 'unknown_sku' }Runs steps concurrently. The first to finish, whether it succeeds or fails, decides the result, and the others are aborted.
A step that waits ms milliseconds: [] = sleep(100). It is aborted with its
surroundings.
Runs step and handles some of its errors by name, like recover for steps
(and like Effect's catchTags). The handled names leave the error type. A
handler returns a value (the step succeeds with it), a Result, or another step
to run instead, whose errors and services are added to the type.
import { TaggedError, err, fn, ok, orElse, run, service } from '@folsom/defect';
import type { Result } from '@folsom/defect';
class NotFound extends TaggedError('NotFound')<{ id: string }> {}
const users = service<{ get(id: string): Promise<Result<string, NotFound | 'db_down'>> }>()('users');
const nameOf = fn((id: string, [name] = orElse(users.get(id), { NotFound: () => 'guest' })) => name);
// nameOf: (id: string) => Eff<string, "db_down", Provides<"users", …>>
const impl = { get: async (id: string) => (id === '1' ? ok('Ada') : err(new NotFound({ id }))) };
console.log(await run(nameOf('2'), { users: impl })); // Ok { ok: true, value: 'guest' }A step that opens a resource and registers close to run when the enclosing
fn (or run) finishes, however it finishes: success, failure, a throw, or an
abort. Resources close in the reverse order they were opened. If the function
failed and a close also throws, the original error is kept and the close
error is attached to it as a SuppressedError, as with using.
import { acquire, fn, run } from '@folsom/defect';
const open = (name: string) =>
acquire(
async () => (console.log(`open ${name}`), name),
(n) => console.log(`close ${n}`),
);
const job = fn(([a] = open('a'), [b] = open('b')) => `${a}+${b}`);
console.log(await run(job()));
// open a / open b / close b / close a
// Ok { ok: true, value: 'a+b' }Starts step in the background and returns a Fiber right away. Use
join to wait for it; fiber.cancel() aborts it. If the fiber is
still running when the enclosing fn finishes, it is aborted and the fn
waits for it to stop. A fiber's failure is reported only by join.
A step that waits for a forked step and gives its value, or fails with its error.
import { fn, fork, join, run, sleep } from '@folsom/defect';
const slow = (ms: number, value: string) => fn(([] = sleep(ms)) => value)();
const both = fn(([a] = fork(slow(30, 'a')), [b] = slow(30, 'b'), [aa] = join(a)) => aa + b);
const started = Date.now();
console.log(await run(both()), `${Date.now() - started}ms`); // Ok { …, value: 'ab' } ~30ms, not 60Records the value of every finished step in journal, an object you provide
and can save, keyed by each step's position. When you run the same step again
with that journal, for example after the process crashed, steps already in the
journal are not run again: they return the recorded value. So a payment step
that succeeded before the crash is not repeated after it.
- Values must survive however you store the journal; usually that means JSON.
- Only successes are recorded, so a step that failed runs again.
- Resources (
acquire), fibers (fork) andneedare never recorded; they are opened again on the next run. - Positions are only meaningful for the same code. If you change the steps of a function, start a new journal.
import { durable, fn, run, service } from '@folsom/defect';
const ledger = service<{ charge(cents: number): Promise<string> }>()('ledger');
let charges = 0;
const impl = { charge: async (cents: number) => (charges++, `receipt-${cents}`) };
let crash = true;
const pay = fn((cents: number, [receipt] = ledger.charge(cents)) => {
if (crash) throw new Error('process killed');
return receipt;
});
const journal: Record<string, unknown> = {};
await run(durable(pay(500), journal), { ledger: impl }).catch(() => {});
const saved = JSON.parse(JSON.stringify(journal)) as Record<string, unknown>;
crash = false;
console.log(await run(durable(pay(500), saved), { ledger: impl }), charges); // Ok { …, value: 'receipt-500' } 1Calls onSpan each time a step inside step finishes, with a Span:
path: the step's position, such as/1/0(the first step of the function that is the second step).name: the service method (ledger.charge), the name of the function given tofn, or the combinator.ms: how long it took.okanderror: how it ended.replayed:trueif the value came from adurablejournal.
import { err, fn, ok, run, service, trace } from '@folsom/defect';
import type { Result } from '@folsom/defect';
const ledger = service<{ charge(cents: number): Promise<Result<string, 'declined'>> }>()('ledger');
const pay = fn(function pay(cents: number, [r] = ledger.charge(cents)) {
return r;
});
const order = fn(function order([a] = pay(5), [b] = pay(5000)) {
return [a, b];
});
const impl = { charge: async (cents: number) => (cents > 100 ? err('declined') : ok('r')) };
await run(
trace(order(), (s) => console.log(s.path, s.name, s.ok ? 'ok' : s.error)),
{ ledger: impl },
);
// /0/0 ledger.charge ok
// /0 pay ok
// /1/0 ledger.charge declined
// /1 pay declinedEff<T, E, R>: a step that succeeds withT, fails withEand needs the servicesR. Structurally it'sreadonly [T]plus a brand, which is what makes[x] = stepgive aT.Result<T, E>,Ok<T>,Err<E>: see Results.Provides<Name, Api>: the requirement a step gets from using a service:{ readonly [Name]: Api }.Outcome<X>: theResultthat running the stepXresolves to. Handy for typing a function that handlesrun's result.ValueOf<R>,ErrorOf<R>: the value and error types of a Result type.ValueIn<X>,ErrorIn<X>,NeedsIn<X>: the value, error and requirement types of a step, or of a function that a combinator accepts.Layer<T, E, R>,Fiber<T, E>,Span,RetryOptions<E>,TaskLike,Service<Api, Name>,TaggedErrorClass<Name>,NameOf<E>,Named<E, N>: the types of the values described above.TimeoutErrorandMissingServiceare the two errors the library creates itself.
These follow from the design. Most can't be fixed without changing TypeScript.
- Steps must be destructured in parameter defaults. A step destructured in
the function body runs, but its errors and services don't reach the type. A
step can't be
awaited; inside anasyncbody, callrunon it and handle the Result yourself. - Plain defaults run once per step. A function with n steps evaluates its defaults n + 1 times. That is negligible next to the steps themselves, but the defaults must be deterministic and free of side effects, or the steps will not line up between passes.
- A
try/catchin a default can see the internal signal a step throws. If it swallows it, the step still runs as long as another step follows; otherwisefnreports the mistake as an error. fnreads parameter names from the function's source to tell inputs from steps at run time. It understands strings, template literals, comments and nested brackets, but not regular expression literals; a default containing a regex with an unbalanced bracket can make it miscount. It also treats an array-destructured input that has a default ([a, b] = [1, 2]) as a step.- Service errors have to be Results. A service method that rejects or
throws produces a defect, not a typed failure. Wrap such calls with
attempt.
defect covers Effect's core: typed failures and defects, services with
compile-time checking, layers, catchTags, retries with schedules, timeouts,
concurrency with a limit, racing, interruption, scoped resources, and fibers.
It adds durable execution, which Effect provides through a separate workflow
package.
It doesn't have Effect's fiber runtime and scheduler, supervision trees, STM,
streams, Schema, or the Cause structure that records concurrent failures
together. If you need those, use Effect.