Skip to content
folsomintelPublic

About

Effect-style typed errors, services, retries and resources for TypeScript, with no generators: a function's steps are its parameter defaults.

Topics

Resources

Stars

27 stars

Watchers

0 watching

Forks

Repository files navigation

defect

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.

Contents

Install

npm install @folsom/defect

Requires Node 24 or later (it uses AsyncLocalStorage, AbortSignal.any and SuppressedError). It has no dependencies, and ships ES modules with type declarations.

How it works

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.

Writing steps

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, so ids.map(getUser) works.
  • A step is [x] = step or [] = step, where step is anything that returns an Eff: a service method, another fn, or a combinator. The task can also be built elsewhere and only destructured here.
  • Plain defaults after a step (name above) 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 be async.
  • Throwing is for bugs. A throw (a defect) is not caught: it rejects the promise run returns. Expected failures are returned as err(…).

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.

API

Results

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'

ok(value?)

Makes an Ok<T>. With no argument, an Ok<void>.

err(error)

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.

isResult(value)

true if value was made by ok() or err().

unwrap(result)

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.

attempt(fn, onThrow?)

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); // false

Errors

Errors 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.

TaggedError(name)

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); // Unavailable

match(error, cases)

Calls 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'

recover(result, cases)

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.

Steps

fn(function)

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:

  • T is what the body returns: its value, or the value of the Ok or step it returns.
  • E is the union of every step's errors, plus any err the body returns.
  • R is 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(…) { … }).

run(step, services?)

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.

signal()

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 50ms

Library steps (service calls, sleep, the combinators) stop waiting as soon as their signal aborts, even if the promise they wrapped never settles.

Services

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.

service<Api>()(name)

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>> (or Result<T, E>) becomes a step with value T and error E;
  • a method returning Promise<T> (or T) 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.

need(service)

A step whose value is the service object itself, for when you want to call it directly or pass it on: [clock] = need(clockService).

provide(step, services)

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' }

layer(step)

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 }

Combinators

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.

retry(options, 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 (attempt counts from 0).
  • while: a predicate on the error; return false to 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 }

exponential(base, options?)

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, …

timeout(ms, step)

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.

all(steps, options?)

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' }

race(steps)

Runs steps concurrently. The first to finish, whether it succeeds or fails, decides the result, and the others are aborted.

sleep(ms)

A step that waits ms milliseconds: [] = sleep(100). It is aborted with its surroundings.

Error handling

orElse(step, cases)

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' }

Resources and fibers

acquire(open, close)

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' }

fork(step)

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.

join(fiber)

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 60

Durable execution and tracing

durable(step, journal)

Records 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) and need are 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' } 1

trace(step, onSpan)

Calls 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 to fn, or the combinator.
  • ms: how long it took.
  • ok and error: how it ended.
  • replayed: true if the value came from a durable journal.
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 declined

Types

  • Eff<T, E, R>: a step that succeeds with T, fails with E and needs the services R. Structurally it's readonly [T] plus a brand, which is what makes [x] = step give a T.
  • 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>: the Result that running the step X resolves to. Handy for typing a function that handles run'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.
  • TimeoutError and MissingService are the two errors the library creates itself.

Limitations

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 an async body, call run on 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/catch in a default can see the internal signal a step throws. If it swallows it, the step still runs as long as another step follows; otherwise fn reports the mistake as an error.
  • fn reads 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.

Compared with Effect

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.

License

MIT

About

Effect-style typed errors, services, retries and resources for TypeScript, with no generators: a function's steps are its parameter defaults.

Topics

Resources

Stars

27 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages