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.
- 1Your first serviceDescribe it, implement it, use it, run it.
- 2Wire an applicationServices that need services, opened and closed for you.
- 3Handle failureExpected errors in the type, bugs kept apart.
- 4Test itSwap implementations instead of mocking modules.
- 5Open things lazilyOnce, on first use, shared by everyone waiting.
- 6Coalesce writesCallers add one note; the disk sees a hundred.
- 7Time out, retry, cancelWork that stops when nobody wants it.
Your first service
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.
- 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 typeNotesis the contract, the valueNotesis 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') - Implement it.
Notes.maketakes a generator that returns the implementation. Parameter types come from the contract, sotextneeds 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]), } }) - Use it.
Inside
fx(function* …),yield* Notesgives you the service, andyield*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'> - Run it with an implementation.
const result = await run(remember('buy milk'), [NotesInMemory]) console.log(result) // Ok([{ id: 1, text: "buy milk" }]) - 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"'.
NotesInMemory with a database-backed implementation and nothing that uses Notes changes.Wire an application
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.
- 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') - Implement the simple ones.
oftakes a value you already have.makeis for an implementation that needs other services: ityield*s them. The comment underLoggerLiveis 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> - Open resources with
acquire.acquirepairs 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> - List everything once, in any order.
runtimebuilds each service the first time something needs it, following theyield*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: closedLeave
ClockLiveout andapp.runstops compiling:TS2339runtime([NotesLive, LoggerLive, ConfigLive]) Property 'run' does not exist on type '"e2: missing service Clock"'.
close(). Adding a dependency to a service is one yield* in its body; the compiler then tells you whether the list still covers it.Handle failure
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.
- Name the failures.
A class with a
_tag.TaggedErrorextendsError, so stack traces andinstanceofwork 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}`) } } - Say which methods can fail, and how.
The second type parameter of
Fxlists what can go wrong.interface Notes { add(text: string): Fx<Note, EmptyNote> get(id: number): Fx<Note, NoteNotFound> } const Notes = service<Notes>()('Notes') - Write checks as plain functions.
okanderrbuild aResult. 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()) - Let
yield*stop at the first failure.Yielding a
Resultcontinues 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) }, } }) - Watch the errors add up by themselves.
duplicatecalls a method that can fail withNoteNotFoundand one that can fail withEmptyNote, 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) - Recover from the ones you choose.
catchAllsees expected failures only. Returnokto recover, orerrto pass the failure on. The type follows:NoteNotFoundis gone,EmptyNoteremains.// 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) - 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)
Test it
Code that imports its dependencies needs module mocks to test. Code that asks for services needs nothing special: run it with different implementations.
- 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'> - Write doubles as ordinary providers.
A fixed clock is
Clock.of(…)with a fixed date. A store with known contents isNotes.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 }))), }) - 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) - 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)
make for doubles with state, so each run gets a fresh one; and if nothing in a test waits, runSync runs it without await.Open things lazily
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.
- Wrap the expensive part in
memo.memodoes not run anything. It returns a handle; the firstyield*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()] }), } }) - 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') - 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()
if (!opened). If the open fails, the readers waiting for it get the failure and the next read tries again.Coalesce writes
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.
- Give the store a batch.
batchtakes an operation over many items and hands backenqueuefor one item. Items added in the same moment, or untilmaxSize, 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), } }) - 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
add instead of enqueue.Time out, retry, cancel
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.
- Wrap the request, and pass the signal through.
fromPromisegives the request anAbortSignaland maps a rejection to a typed error. Withfetchyou 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')), }) - Add the policy around it, not inside it.
retryruns the effect again on failure, following a schedule.timeoutgives 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])) - Cancel, and watch the request stop.
Interrupting a fiber aborts the signal handed to its request. The same happens when a
timeoutfires or araceis 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] )
Where to go from here
Move a service to a worker
The same yield* Notes, implemented on another thread. A change to the provider list.
Services in depth
Defaults, per-method dependencies, extension points, and every diagnostic.
services.html →Experiment
Every guide is in the playground. Break one and read the error.
playground.html →