e2 · guides

Build a small app, one idea at a time

Seven short guides grow one notes app. Each starts from a problem you have probably met, adds one idea, and ends with code you can run. Every code block on this page is cut from a playground example that is type-checked and tested along with the library, so what you read is what runs.

  1. 1Your first serviceDescribe it, implement it, use it, run it.
  2. 2Wire an applicationServices that need services, opened and closed for you.
  3. 3Handle failureExpected errors in the type, bugs kept apart.
  4. 4Test itSwap implementations instead of mocking modules.
  5. 5Open things lazilyOnce, on first use, shared by everyone waiting.
  6. 6Coalesce writesCallers add one note; the disk sees a hundred.
  7. 7Time out, retry, cancelWork that stops when nobody wants it.
Guide 1

Your first service

Run this guide ▸

A service is anything your code needs but should not build itself: storage, a clock, an HTTP client. You describe it once, ask for it where you need it, and decide what fills it at the edge of the program.

  1. Describe what it does.

    An interface says what the service can do. service<Notes>()('Notes') makes the key the program asks for. They share a name on purpose: the type Notes is the contract, the value Notes is the key.

    interface Note {
      readonly id: number
      readonly text: string
    }
    
    // What a notes store can do. Nothing about how.
    interface Notes {
      add(text: string): Fx<Note>
      list(): Fx<readonly Note[]>
    }
    
    // The key the program asks for. Same name as the interface, on purpose.
    const Notes = service<Notes>()('Notes')
  2. Implement it.

    Notes.make takes a generator that returns the implementation. Parameter types come from the contract, so text needs no annotation.

    const NotesInMemory = Notes.make(function* () {
      const notes: Note[] = []
      return {
        add: (text) =>
          sync(() => {
            const note = { id: notes.length + 1, text }
            notes.push(note)
            return note
          }),
        list: () => sync(() => [...notes]),
      }
    })
  3. Use it.

    Inside fx(function* …), yield* Notes gives you the service, and yield* on anything it returns gives you the result. Nobody writes the type on the last line; e2 works out that this needs 'Notes'.

    const remember = fx(function* (text: string) {
      const notes = yield* Notes
      yield* notes.add(text)
      return yield* notes.list()
    })
    // remember: (text: string) => Fx<readonly Note[], never, 'Notes'>
  4. Run it with an implementation.
    const result = await run(remember('buy milk'), [NotesInMemory])
    console.log(result) // Ok([{ id: 1, text: "buy milk" }])
  5. Forget the implementation, and see what happens.

    Change the list to []. The program no longer compiles, and the message says what is missing:

    TS2345run(remember('buy milk'), [])
    Type 'Fx<readonly Note[], never, "Notes">' is not assignable to type '"e2: missing service Notes"'.
What you gotA dependency the compiler knows about. Replace NotesInMemory with a database-backed implementation and nothing that uses Notes changes.
Guide 2

Wire an application

Run this guide ▸

Real services depend on each other. The notes store needs a clock and a logger; the logger needs configuration. Usually that means building things in the right order and remembering to close them. Here each implementation asks for what it needs, and the runtime works out the rest.

  1. Add the contracts.

    Same shape as before: an interface and a key for each.

    interface Config {
      readonly appName: string
    }
    const Config = service<Config>()('Config')
    
    interface Clock {
      readonly now: Fx<Date>
    }
    const Clock = service<Clock>()('Clock')
    
    interface Logger {
      info(message: string): Fx<void>
    }
    const Logger = service<Logger>()('Logger')
    
    interface Notes {
      add(text: string): Fx<Note>
      list(): Fx<readonly Note[]>
    }
    const Notes = service<Notes>()('Notes')
  2. Implement the simple ones.

    of takes a value you already have. make is for an implementation that needs other services: it yield*s them. The comment under LoggerLive is its inferred type: it provides 'Logger' and needs 'Config'. Nobody wrote that list down; it comes from the body.

    // A value you already have: `of`.
    const ConfigLive = Config.of({ appName: 'notes' })
    const ClockLive = Clock.of({ now: sync(() => new Date()) })
    
    // Something that needs other services: `make`, and yield them.
    const LoggerLive = Logger.make(function* () {
      const config = yield* Config
      return {
        info: (message) => sync(() => console.log(`[${config.appName}] ${message}`)),
      }
    })
    // Provider<'Logger', 'Config', never>
  3. Open resources with acquire.

    acquire pairs opening something with closing it. The close runs when the application stops, newest first, whether it stops cleanly or not.

    const NotesLive = Notes.make(function* () {
      const clock = yield* Clock
      const log = yield* Logger
    
      // Opened while the app starts, closed when it stops.
      const storage = yield* acquire(
        () => {
          console.log('storage: opened')
          return new Map<number, Note>()
        },
        () => console.log('storage: closed')
      )
    
      return {
        add: (text) =>
          gen(function* () {
            const note = { id: storage.size + 1, text, createdAt: yield* clock.now }
            storage.set(note.id, note)
            yield* log.info(`added note ${note.id}`)
            return note
          }),
        list: () => sync(() => [...storage.values()]),
      }
    })
    // Provider<'Notes', 'Clock' | 'Logger', never>
  4. List everything once, in any order.

    runtime builds each service the first time something needs it, following the yield*s, and keeps them for as many runs as you like. app.graph() shows what was built and what each one asked for.

    // One list, any order. Each service is built once, when first needed.
    const app = await runtime([NotesLive, LoggerLive, ClockLive, ConfigLive])
    
    await app.run(remember('buy milk'))
    const all = await app.run(remember('call mum'))
    console.log(all.ok && all.value.map((note) => note.text)) // ["buy milk", "call mum"]
    
    await app.close() // storage: closed

    Leave ClockLive out and app.run stops compiling:

    TS2339runtime([NotesLive, LoggerLive, ConfigLive])
    Property 'run' does not exist on type '"e2: missing service Clock"'.
What you gotNo container configuration, no construction order, no forgotten close(). Adding a dependency to a service is one yield* in its body; the compiler then tells you whether the list still covers it.
Guide 3

Handle failure

Run this guide ▸

Things go wrong in two different ways. Some failures are outcomes you expect: the note does not exist, the text is empty. Others are bugs. e2 keeps them apart: expected failures are part of the type, so the compiler tracks them; bugs are not, so they cannot hide among them.

  1. Name the failures.

    A class with a _tag. TaggedError extends Error, so stack traces and instanceof work as usual.

    class EmptyNote extends TaggedError<'EmptyNote'> {
      readonly _tag = 'EmptyNote' as const
    }
    
    class NoteNotFound extends TaggedError<'NoteNotFound'> {
      readonly _tag = 'NoteNotFound' as const
      constructor(readonly id: number) {
        super(`there is no note ${id}`)
      }
    }
  2. Say which methods can fail, and how.

    The second type parameter of Fx lists what can go wrong.

    interface Notes {
      add(text: string): Fx<Note, EmptyNote>
      get(id: number): Fx<Note, NoteNotFound>
    }
    const Notes = service<Notes>()('Notes')
  3. Write checks as plain functions.

    ok and err build a Result. No effect, no service: just a function you can call anywhere.

    // An ordinary function. No effects, no service, just a Result.
    const validate = (text: string): Result<string, EmptyNote> =>
      text.trim() === '' ? err(new EmptyNote('a note needs some text')) : ok(text.trim())
  4. Let yield* stop at the first failure.

    Yielding a Result continues with its value, or ends the body with its error. It is the ? operator from Rust, in TypeScript.

    const NotesLive = Notes.make(function* () {
      const notes = new Map<number, Note>()
      return {
        add: (text) =>
          gen(function* () {
            const clean = yield* validate(text) // an EmptyNote stops here
            const note = { id: notes.size + 1, text: clean }
            notes.set(note.id, note)
            return note
          }),
        get: (id) => {
          const note = notes.get(id)
          return note === undefined ? err(new NoteNotFound(id)) : ok(note)
        },
      }
    })
  5. Watch the errors add up by themselves.

    duplicate calls a method that can fail with NoteNotFound and one that can fail with EmptyNote, so its type says both.

    const duplicate = fx(function* (id: number) {
      const notes = yield* Notes
      const original = yield* notes.get(id)
      return yield* notes.add(original.text)
    })
    // duplicate: (id: number) => Fx<Note, NoteNotFound | EmptyNote, 'Notes'>
    const setup = fx(function* () {
      const notes = yield* Notes
      yield* notes.add('buy milk')
      return yield* duplicate(1)
    })
    console.log(await run(setup(), [NotesLive])) // Ok({ id: 2, text: "buy milk" })
    console.log(await run(duplicate(7), [NotesLive])) // Err(NoteNotFound: there is no note 7)
  6. Recover from the ones you choose.

    catchAll sees expected failures only. Return ok to recover, or err to pass the failure on. The type follows: NoteNotFound is gone, EmptyNote remains.

    // Handle one failure; the other stays in the type.
    const duplicateOrSkip = (id: number) =>
      catchAll((error: NoteNotFound | EmptyNote) => (error._tag === 'NoteNotFound' ? ok(undefined) : err(error)))(
        duplicate(id)
      )
    // (id: number) => Fx<Note | undefined, EmptyNote, 'Notes'>
    
    console.log(await run(duplicateOrSkip(7), [NotesLive])) // Ok(undefined)
  7. Leave bugs as bugs.
    // A thrown exception is a bug, not an outcome: it is not in the type,
    // catchAll does not see it, and `run` rejects. `runExit` shows everything.
    const broken = sync((): Note => {
      throw new Error('index out of range')
    })
    const exit = await runExit(broken, [])
    if (!exit.ok) console.log(Cause.pretty(exit.error)) // Die(Error: index out of range)
What you gotEvery expected failure is in a signature, so a new one is a compile error at every place that has to decide about it. Bugs reject loudly instead of being caught by a handler written for something else.
Guide 4

Test it

Run this guide ▸

Code that imports its dependencies needs module mocks to test. Code that asks for services needs nothing special: run it with different implementations.

  1. Start from the code you want to test.

    It reads the clock and the notes. It has no idea it is under test.

    // The code under test. It knows nothing about tests.
    const today = fx(function* () {
      const notes = yield* Notes
      const clock = yield* Clock
      const now = yield* clock.now
      const all = yield* notes.list()
      return all.filter((note) => note.createdAt.toDateString() === now.toDateString())
    })
    // today: () => Fx<Note[], never, 'Notes' | 'Clock'>
  2. Write doubles as ordinary providers.

    A fixed clock is Clock.of(…) with a fixed date. A store with known contents is Notes.of(…). Small functions make them reusable.

    // A double is just another provider for the same key.
    const At = (iso: string) => Clock.of({ now: ok(new Date(iso)) })
    
    const Seeded = (texts: readonly string[], createdAt: Date) =>
      Notes.of({
        add: () => ok({ id: 0, text: '', createdAt }),
        list: () => ok(texts.map((text, index) => ({ id: index + 1, text, createdAt }))),
      })
  3. Run the same program against different lists.
    const check = (name: string, pass: boolean) => console.log(pass ? '✓' : '✗', name)
    
    const morning = await run(today(), [Seeded(['buy milk'], new Date('2026-03-01T09:00')), At('2026-03-01T18:00')])
    check('a note from this morning counts as today', morning.ok && morning.value.length === 1)
    
    const yesterday = await run(today(), [Seeded(['buy milk'], new Date('2026-02-28T09:00')), At('2026-03-01T18:00')])
    check('a note from yesterday does not', yesterday.ok && yesterday.value.length === 0)
  4. Replace only what you need to.

    Use the real store and a fake clock. Every other service stays real.

    // The real implementation, with only the clock replaced.
    const added = await run(
      gen(function* () {
        const notes = yield* Notes
        yield* notes.add('call mum')
        return yield* today()
      }),
      [NotesInMemory, At('2026-03-01T12:00')]
    )
    check('a note added now is today', added.ok && added.value.length === 1)
What you gotNo mocking library and no module patching. A list that misses a double is a compile error like any other. Prefer make for doubles with state, so each run gets a fresh one; and if nothing in a test waits, runSync runs it without await.
Guide 5

Open things lazily

Run this guide ▸

Some things are slow to open and not always needed: a database, a search index, a whole feature. You want each opened once, on first use, shared by everyone who asks while it opens, and closed at shutdown. And you do not want the code that uses it to know any of that.

  1. Wrap the expensive part in memo.

    memo does not run anything. It returns a handle; the first yield* of the handle opens the database, and everything after that reuses it.

    const NotesLive = Notes.make(function* () {
      // Not opened here. `db` is a handle: the first read opens the database,
      // reads that arrive meanwhile wait for that same open, later reads reuse it.
      const db = yield* memo(acquire(openDatabase, () => log('database: closed')))
      return {
        list: () =>
          gen(function* () {
            const rows = yield* db
            return [...rows.values()]
          }),
      }
    })
  2. Keep whole services out of startup with lazy().

    Services are built at startup by default, so a broken one fails fast. lazy(…) in the list opts one out: it is built the first time something asks for it.

    interface Export {
      toMarkdown(): Fx<string>
    }
    const Export = service<Export>()('Export')
    
    const ExportLive = Export.make(function* () {
      log('export: built, because someone used it')
      const notes = yield* Notes
      return {
        toMarkdown: () =>
          gen(function* () {
            const all = yield* notes.list()
            return all.map((note) => `- ${note.text}`).join('\n')
          }),
      }
    })
    
    // lazy(): not built at startup at all.
    const app = await runtime([NotesLive, lazy(ExportLive)])
    log('app: started, database untouched')
  3. Use them as if they were ready.

    Three reads arrive together; the database opens once and all three get it. The export service is built only when the export runs.

    const list = fx(function* () {
      const notes = yield* Notes
      return yield* notes.list()
    })
    
    await app.run(allConcurrent([list(), list(), list()])) // one open, three readers
    log('app: three reads done')
    
    const markdown = await app.run(
      gen(function* () {
        const exporter = yield* Export
        return yield* exporter.toMarkdown()
      })
    )
    log('app: export', markdown)
    await app.close()
What you gotStartup cost moves to first use without a single if (!opened). If the open fails, the readers waiting for it get the failure and the next read tries again.
Guide 6

Coalesce writes

Run this guide ▸

Saving notes one at a time is easy to write and slow to run. Batching usually means changing every caller to collect notes first. Here the store batches its own writes, and callers keep adding one note at a time.

  1. Give the store a batch.

    batch takes an operation over many items and hands back enqueue for one item. Items added in the same moment, or until maxSize, go to one call.

    const NotesLive = Notes.make(function* () {
      const rows = new Map<number, Note>()
      let writes = 0
    
      // One operation over many notes; callers hand it one note at a time.
      const disk = yield* batch(
        (notes: readonly Note[]) =>
          sync(() => {
            writes += 1
            console.log(`write #${writes}: ${notes.length} notes`)
            return notes
          }),
        { maxSize: 100 }
      )
    
      return {
        add: (text) =>
          gen(function* () {
            const note = { id: rows.size + 1, text }
            rows.set(note.id, note) // readable now
            yield* disk.enqueue(note) // durable shortly, with its neighbours
            return note
          }),
        saved: sync(() => writes),
      }
    })
  2. Call it one note at a time.
    const importNotes = fx(function* (texts: readonly string[]) {
      const notes = yield* Notes
      yield* forEach((text: string) => notes.add(text))(texts)
      return texts.length
    })
    
    const lines = Array.from({ length: 250 }, (_, index) => `note ${index + 1}`)
    console.log(await run(importNotes(lines), [NotesLive]))
    // write #1: 100 notes
    // write #2: 100 notes
    // write #3: 50 notes   <- flushed when the run ends; nothing is lost
What you got250 calls, three writes, and nothing lost at the end: whatever is still queued is written when the run's scope closes. When a caller needs its own result back, the DataLoader pattern, use add instead of enqueue.
Guide 7

Time out, retry, cancel

Run this guide ▸

Syncing notes to a server can be slow, can fail, and can become pointless when the user navigates away. Each of those usually gets its own mechanism. Here they are one: a request is an effect, and timeouts, retries and cancellation all apply to effects.

  1. Wrap the request, and pass the signal through.

    fromPromise gives the request an AbortSignal and maps a rejection to a typed error. With fetch you would pass the signal straight to it. The playground version uses a stand-in that logs when it is aborted.

    interface Sync {
      push(delay: number): Fx<string, Offline>
    }
    const Sync = service<Sync>()('Sync')
    
    const SyncLive = Sync.of({
      // fromPromise hands the fiber's AbortSignal to the request.
      push: (delay) => fromPromise((signal) => upload(signal, delay), () => new Offline('server unreachable')),
    })
  2. Add the policy around it, not inside it.

    retry runs the effect again on failure, following a schedule. timeout gives up after a deadline and stops whatever is still running. Both wrap the call; the service does not change.

    const syncNow = fx(function* (delay: number) {
      const sync = yield* Sync
      return yield* sync.push(delay)
    })
    
    // Retry offline failures with backoff, and give up after a second overall.
    const reliable = (delay: number) =>
      timeout(1_000, () => new TooSlow('gave up'))(retry(Schedule.exponential(100).upTo(5))(syncNow(delay)))
    // (delay: number) => Fx<string, Offline | TooSlow, 'Sync'>
    
    log('result:', await run(reliable(50), [SyncLive]))
  3. Cancel, and watch the request stop.

    Interrupting a fiber aborts the signal handed to its request. The same happens when a timeout fires or a race is lost.

    // Cancelling the caller cancels the request.
    await run(
      gen(function* () {
        const background = yield* fork(syncNow(5_000))
        yield* sleep(100)
        yield* interruptFiber(background) // upload: aborted
      }),
      [SyncLive]
    )
What you gotWork started by something that has given up stops too. Forked work cannot outlive what forked it, and cleanup still runs, so nothing is left half-done in the background.
Next

Where to go from here