Home / Part 2 / 2.7 Putting it together
Section 2.7
Putting it together
Sections 2.1–2.6 built the runtime. This one uses it — no new code — to write a real program: concurrent, retrying, dependency-injected, with every failure handled at the edge.
Uses: service · provide · tryPromise · retry · timeout · catchTags · forEachConcurrent · pipe
No new runtime — this section's runtime.ts just re-exports the
finished toy from 2.6. Run example.ts with bun and watch the retry
heal a glitchy endpoint.
The task
Load a user, their posts, and a comment count for each post — concurrently — retrying transient network errors, with an overall timeout, and every failure handled once at the edge.
That one sentence touches every part of the runtime. Nothing new is needed: the ~20 functions from 2.1–2.6 cover all of it.
| What the task needs | Built in |
|---|---|
| A swappable HTTP client, not threaded through every call | service / provide — 2.3 |
| Turn a promise (a fetch) into an effect | tryPromise / async — 2.2 |
| Fetch all comment counts at once | forEachConcurrent — 2.5 |
| Give up after N seconds | timeout — 2.5 |
| Retry only network errors | retry — 2.4 |
| Handle each failure by its tag, at the edge | catchTags — 2.4 |
| Read top-to-bottom | pipe — 2.6 |
Errors and the service
The errors are plain tagged objects — the shape catchTags dispatches on. The HTTP
client is declared as a service: a dependency recorded in R until
we provide it, not an argument threaded through every function.
interface NotFoundError { readonly _tag: "NotFound"; readonly url: string }
interface NetworkError { readonly _tag: "Network"; readonly message: string }
interface Http {
get: <T>(url: string) => Effect<T, NotFoundError | NetworkError>
}
const Http = makeTag<Http>()("Http")
The logic, in pipe style
Each layer asks the context for the client and never names it again. The requirement
(R = "Http") rides up the type on its own:
const getUser = (id: number) =>
flatMap(service(Http), (http) => http.get<User>(`/users/${id}`))
const getPosts = (userId: number) =>
flatMap(service(Http), (http) => http.get<Post[]>(`/users/${userId}/posts`))
const getCommentCount = (postId: number) =>
flatMap(service(Http), (http) =>
map(http.get<unknown[]>(`/posts/${postId}/comments`), (comments) => comments.length))
const loadUserPosts = (userId: number) =>
pipe(
getUser(userId),
flatMap((user) => getPosts(user.id)),
flatMap((posts) =>
// fetch every post's comment count at once, not one by one
forEachConcurrent(posts, (post) =>
map(getCommentCount(post.id), (comments): PostWithCount => ({ post, comments })))))
Real Effect lets you write this with generator syntax —
Effect.gen(function* () { const user = yield* getUser(id); const posts = yield*
getPosts(user.id); ... }). We never built that. For a straight chain like
loadUserPosts, pipe handles it fine. gen earns its keep
when a later step needs a value bound several steps earlier: with pipe
you'd have to nest to keep it in scope, while gen lets you just refer to it. That's
the case it's for.
The client, provided once
Here's a fake in-memory client. Swap it for one built on tryPromise(() => fetch(...))
and nothing in the logic above changes. retry sits at the source, so any layer that
calls get inherits it:
const HttpLive: Http = {
get: <T>(url: string) =>
pipe(
flatMap(sleep(40), (): Effect<T, NotFoundError | NetworkError> => {
if (url === "/posts/2/comments" && glitches < 2) { // a transient glitch, on purpose
glitches++
return fail(network(`temporary glitch on ${url}`))
}
const body = DATA[url]
return body === undefined ? fail(notFound(url)) : succeed(body as T)
}),
retry({ times: 3, while: (e) => e._tag === "Network" }) // retry network errors only
),
}
Compose and run
Every line is an operator on a value. Nothing executes until runPromise:
// the annotation says "no errors remain". that's what forces us to handle every
// case below: drop the Timeout handler and TimeoutError stays in the channel, no
// longer matches `never`, and this line stops compiling.
const program: Effect<PostWithCount[] | string, never, never> = pipe(
loadUserPosts(1),
timeout(2000), // built-in; adds TimeoutError to the type
tap((posts) => sync(() => console.log(`loaded ${posts.length} posts`))),
// catchTags is the one combinator we left data-first (2.6), so we hand it `self`
(self) => catchTags(self, {
NotFound: (e) => sync(() => `not found: ${e.url}`),
Network: (e) => sync(() => `network: ${e.message}`),
Timeout: (e) => sync(() => `timed out after ${e.ms}ms`),
}),
provide(Http, HttpLive), // satisfy the dependency, at the edge
)
runPromise(program).then((result) => console.log("result:", result))
The output shows the retry healing the glitch, then the result:
↻ /posts/2/comments glitched (attempt 1) — retry will heal it
↻ /posts/2/comments glitched (attempt 2) — retry will heal it
loaded 2 posts
result: [
{ post: { id: 1, title: "On computation" }, comments: 3 },
{ post: { id: 2, title: "On the Analytical Engine" }, comments: 1 },
]
What the types force here, and what they don't — worth being precise, because it's easy to
overstate. Dependencies are enforced: provide has to bring
R to never, or the runners won't accept the program. Errors are
not — runPromise (like real Effect's) will take an effect with leftover
errors and just reject the promise if one happens. What makes you handle every case is the
annotation: by declaring program as
Effect<…, never, never> you ask the compiler to hold you to "no errors left."
timeout added TimeoutError, so deleting its handler leaves an error that
no longer matches never — and it stops compiling. Drop the annotation and it compiles
fine, unhandled error and all. Handling everything is something you opt into by committing to an
empty error channel.
Recap
Part 1 started with a claim: an Effect is a description, not a running program. Seven sections later, you've built it and used it to write the program above:
- An Effect is a tree of tagged nodes (
succeed,flatMap,async,service, …). - A fiber is a loop with registers and a stack that walks the tree.
- Sequencing is a stack of postponed steps. Failure is a flag that throws away the success steps until it reaches a handler. Async is the loop returning out of itself and being called again later. Dependencies are a context register. Concurrency is more than one fiber.
- Everything else —
map,tap,retry,timeout,catchTags,forEachConcurrent— is built from that same handful of primitives. - pipe / dual are sugar on top, gone before anything runs.
That's all Effect is underneath: a data structure, and a loop that walks it. The program above is built from nothing more.