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.
Three questions every program answers, usually by convention
| Question | Usually answered by | In 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.
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.
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.
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.
a contract and its key share a nameimport { 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
an implementation yields what it needsexport 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>
a program, and the list it runs againstconst 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'))
Everything you can import, grouped by what it is for
| Area | Names | Page |
|---|---|---|
| Build an effect | gen fx sync suspend async_ fromPromise sleep ok/succeed err/fail | Effects |
| Transform one | map flatMap catchAll mapError attemptFx | Effects |
| Outcomes | Result (Ok, Err) · Cause (Fail, Die, Interrupt) · Exit | Effects |
| Define a service | service<Api>()('Name') · service('Name', function* () {}) | Services |
| Implement one | Db.make(function* () {}) · Db.of(value) · lazy(provider) | Services |
| Run | run runExit runSync runSyncExit runtime → rt.run rt.fork rt.graph rt.close | Services |
| Share and defer | memo(fx, { eager }) · batch(run, { window, maxSize }) | Lazy init & batching |
| Fibers | fork join joinExit interruptFiber race allConcurrent timeout forEach | Concurrency |
| Resources | scoped acquire addFinalizer ensuring | Concurrency |
| Policies | retry repeat Schedule (recurs spaced exponential stop, .upTo .maxDelay .jittered .whileError) | Concurrency |
| Workers | no API of its own: a service key means the same thing on both sides of a worker | Workers |
| Lint | eslint-plugin-xnim-e2, rule no-floating-fx | Effects |
Read in order, or jump to what you need
Effects
The Fx type, the ways to build one, Result as its pure subset, the three failure channels, and the one footgun.
Services & DI
Contracts and keys, implementations that yield what they need, the flat list, demand-driven construction, and the diagnostics.
services.html →Lazy init & batching
memo for "open once, on first use", batch for "a hundred puts, one write", and why they are the same cell.
Concurrency & resources
Fibers that die with their parent, interruption by AbortSignal, scopes that close in reverse, and schedules.
Workers
A service key is the same key on either side of a worker. Moving work off the main thread is a change to the provider list.
workers.html →Guides
Step by step: define a service, compose an app, test with doubles, open a database lazily, coalesce writes, cross a worker.
guides.html →Playground
Edit and run e2 in the browser, with the same TypeScript the diagnostics are pinned against. Every example on these pages is there.
playground.html →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/.