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

Follow along with the code

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 needsBuilt in
A swappable HTTP client, not threaded through every callservice / provide — 2.3
Turn a promise (a fetch) into an effecttryPromise / async — 2.2
Fetch all comment counts at onceforEachConcurrent — 2.5
Give up after N secondstimeout — 2.5
Retry only network errorsretry — 2.4
Handle each failure by its tag, at the edgecatchTags — 2.4
Read top-to-bottompipe — 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 })))))
What gen would add

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.

getUser(1) getPosts forEachConcurrent getCommentCount(1) → 3 getCommentCount(2) → 1 post 2 glitches → retry heals it Post WithCount[]
Sequential where it must be (you need the user before the posts), concurrent where it can be (the comment counts have no dependency on each other).

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 notrunPromise (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:

What an Effect is
  • 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 elsemap, 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.