e2 · overview

Typed dependency injection and structured control flow for TypeScript

e2 makes three things the compiler checks instead of things you keep in your head: what a piece of code depends on, how it can fail, and what happens to the work it started when it stops. You write ordinary sequential code in generator functions; e2 infers the rest into one type, Fx<Value, Error, Dependency>, and runs it on Bun, in the browser and in workers, with no decorators, no build step and no runtime dependencies.

type Fx<Value, Error, Dependency> deps inferred from yield* wiring one flat list lazy memo · lazy · batch try it playground
Why

Three questions every program answers, usually by convention

QuestionUsually answered byIn e2
What does this code depend on?Imports, which hard-wire one implementation and push tests towards module mocks; or a DI container that wires at runtime from decorators and reflection, and reports a missing binding when the app starts, or when a request first reaches the code that needed it.The type. yield* Db puts 'Db' in it, the requirement travels to every caller, and running without a Db does not compile.
How can it fail?throw. Nothing in a signature says what can go wrong, and every catch receives unknown.The type. Expected failures are a union in the second slot; bugs and cancellation are kept apart from them.
When does it stop?An AbortSignal threaded through by hand, finally blocks you hope run, and promises that keep going after whatever started them has given up.Structure. Forked work dies with its parent, cancellation reaches fetch, and every resource is released in reverse, with the outcome that closed it.

Nobody writes the resulting signature by hand. A missing service is a compile error that names it, and that wording is a feature: src/tests/diagnostics.spec.ts runs tsc and pins it, so a TypeScript upgrade that degrades a message fails CI.

Three ideas

Everything in e2 is one of these three things

An effect is a description. Fx<Value, Error, Dependency> is a value that says what to compute, how it can fail, and which services it needs. Building one does nothing. A generator body sequences effects with yield*, and an interpreter runs the tree later, against a set of services, producing a Result.

gen(function* () {…}) a body that yield*s Fx tree immutable, re-runnable interpreter run / runtime, fibers Result Ok | Err services, by name: Db, Logger, Config
Nothing happens until run. That is what makes an effect testable, retryable, and cancellable.

Dependencies are names, and they are inferred. A service is a contract plus a key. Using it six frames deep with yield* Db puts 'Db' into the effect's type without touching any intermediate signature. Wiring is one flat, unordered list of providers, each built on first demand; a missing one is a compile error that names it.

const getUser = fx(function* (id: string) {
  const db  = yield* Db
  const log = yield* Logger
  yield* log.info(`load ${id}`)
  return yield* db.query(id)
})
// (id: string) => Fx<User, DbError, 'Db' | 'Logger'>

await run(getUser('1'), [DbLive, LoggerLive, ConfigLive])   // order is irrelevant
await run(getUser('1'), [DbLive, ConfigLive])
// Type 'Fx<User, DbError, "Db" | "Logger">' is not assignable to type '"e2: missing service Logger"'.

One cell does the sharing. "Build once, let everyone who asks share it, let a failure be retried" is the same mechanism whether the thing being built is a service, a lazily opened database, or a batch of writes. The runtime keeps one cell per provider; memo hands out anonymous ones; batch collects items into one run. Because it is one implementation, they cannot disagree about joining, failure or teardown.

Quick start

A service, an implementation, a program, a run

npm install e2, or bun add e2. TypeScript 5.5 or later. Or skip the install: every step below runs in the playground, with the compiler's diagnostics live.

1a contract and its key share a name
import { service, sync, run, fx, type Fx } from 'e2'

export interface Config { readonly url: string }
export const Config = service<Config>()('Config')

export interface Db { query(id: string): Fx<User, DbError> }
export const Db = service<Db>()('Db')   // type Db is the contract, value Db is the key
2an implementation yields what it needs
export const ConfigLive = Config.of({ url: 'postgres://localhost' })

export const DbLive = Db.make(function* () {
  const config = yield* Config            // this is the dependency declaration
  const pool = yield* acquire(() => connect(config.url), (p) => p.close())
  return { query: (id) => sync(() => pool.find(id)) }
})
// Provider<'Db', 'Config', never>
3a program, and the list it runs against
const getUser = fx(function* (id: string) {
  const db = yield* Db
  return yield* db.query(id)
})

const result = await run(getUser('1'), [DbLive, ConfigLive])
// Result<User, DbError>
if (result.ok) console.log(result.value)

// A long-lived process builds the graph once:
await using rt = await runtime([DbLive, ConfigLive])
await rt.run(getUser('1'))
The whole surface

Everything you can import, grouped by what it is for

AreaNamesPage
Build an effectgen fx sync suspend async_ fromPromise sleep ok/succeed err/failEffects
Transform onemap flatMap catchAll mapError attemptFxEffects
OutcomesResult (Ok, Err) · Cause (Fail, Die, Interrupt) · ExitEffects
Define a serviceservice<Api>()('Name') · service('Name', function* () {})Services
Implement oneDb.make(function* () {}) · Db.of(value) · lazy(provider)Services
Runrun runExit runSync runSyncExit runtime → rt.run rt.fork rt.graph rt.closeServices
Share and defermemo(fx, { eager }) · batch(run, { window, maxSize })Lazy init & batching
Fibersfork join joinExit interruptFiber race allConcurrent timeout forEachConcurrency
Resourcesscoped acquire addFinalizer ensuringConcurrency
Policiesretry repeat Schedule (recurs spaced exponential stop, .upTo .maxDelay .jittered .whileError)Concurrency
Workersno API of its own: a service key means the same thing on both sides of a workerWorkers
Linteslint-plugin-xnim-e2, rule no-floating-fxEffects
Where to go

Read in order, or jump to what you need

Development

Checks

bun install
bun run check          # typecheck + lint + tests
bun run smoke:browser  # build dist/e2-smoke.js, then open dist/smoke.html
bun run site           # build this documentation and the playground into out/site

The type-level proofs behind the design live in spike/.