Lucas Barake

How Effect Simplifies Your TypeScript Code

This blog post is also available as a video on YouTube above.

I’ve released several videos on Effect, and while it’s an incredibly powerful library, I’ve received many comments suggesting that it feels too complex and that its steep learning curve doesn’t justify the potential benefits of writing more robust code.

So, I’ve decided to create an exercise for you. The idea is simple: try solving the following challenge with whatever libraries you’re comfortable with (or even “vanilla” TypeScript - whatever you prefer). Then, I’ll walk you through the solution using Effect, and I want you to be the judge of whether the library has real merit or not.

The Challenge

Let’s implement a refresh session mechanism integrated into an HTTP client with the following requirements:

  1. Ensure only one computation triggers the refresh process at a time - concurrent refresh operations must be prevented.
  2. Set a default timeout of 5 seconds for the session refresh request to handle transient errors.
  3. Validate that the response body matches the type { readonly newAccessToken: string }
  4. Implement a retry mechanism for the refresh function that:
    • Retries on all errors except 401 (unauthorized) and parse exceptions
    • Uses exponential backoff starting at 1.5 seconds with a 1.5x multiplier
    • Limits retries to 3 attempts
  5. Enable consumers to determine if their original operation should be retried after a refresh. Upon successful refresh, both the initiating request and any queued requests should retry their original operations.
  6. Queue all concurrent requests while a session refresh is in progress.

Bonus:

  1. Design the solution to be composable with support for dependency injection.
  2. Implement telemetry by:
    • Adding spans for all key operations
    • Creating a trace for the entire request lifecycle
    • Including relevant attributes in spans

Implementation Walkthrough

We’ll start off by declaring our access layer for our tokens. For this example, we’ll store them in a reference, though in a real application, you’d most likely retrieve them from:

For demonstration purposes, we’re going to use Effect’s Ref to store our tokens, which gives us a way to handle mutable state immutably. This Ref will act as our source of truth for the current authentication tokens:

import { Effect, Ref, Schema } from "effect";

class AuthTokens extends Schema.Class<AuthTokens>("AuthTokens")({
  accessToken: Schema.String,
  refreshToken: Schema.String
}) {}

const program = Effect.gen(function* () {
  const accessTokensRef = yield* Ref.make(
    AuthTokens.make({
      accessToken: "access-token",
      refreshToken: "refresh-token"
    })
  );
});

A quick note about Schema: By using Schema.Class, we get several powerful features in one go:

Refresh Method

Now, let’s use HttpClient from @effect/platform to make our request to refresh the session:

import { HttpBody, HttpClient } from "@effect/platform";
import { Effect, Ref, Schema } from "effect";

class AuthTokens extends Schema.Class<AuthTokens>("AuthTokens")({
  accessToken: Schema.String,
  refreshToken: Schema.String
}) {}

class RefreshResult extends Schema.Class<RefreshResult>("RefreshResult")({
  newAccessToken: Schema.String,
  newRefreshToken: Schema.String
}) {
  public static readonly decodeUnknown = Schema.decodeUnknown(this);
}
 
const program = Effect.gen(function* () {
  const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
 
  const accessTokensRef = yield* Ref.make(
    AuthTokens.make({
      accessToken: "access-token",
      refreshToken: "refresh-token"
    })
  );
 
  const refresh = Effect.gen(function* () {
    const tokens = yield* Ref.get(accessTokensRef);
 
    const response = yield* httpClient.post("...", {
      body: HttpBody.unsafeJson({
        refreshToken: tokens.refreshToken
      })
    });
 
    const json = yield* response.json;
 
    const result = yield* RefreshResult.decodeUnknown(json);
 
    yield* Ref.update(accessTokensRef, () => ({
      accessToken: result.newAccessToken,
      refreshToken: result.newRefreshToken
    }));
  });
});

As you can see, this isn’t too different from traditional async/await code. The magic happens when we use @effect/platform: it automatically exposes all possible errors (ResponseError and RequestError) and sets up spans for you. This means when you connect it to telemetry, all these operations are automatically tracked and registered.

Implementing Concurrent Access Control

Now let’s tackle our first major challenge: ensuring that only one refresh operation can happen at a time.

For this, we’ll use withSemaphore(1). If you’re new to semaphores, think of them as bouncers at a club who control how many people can enter at once. A semaphore with one permit (like we’re using) is essentially a mutex - it ensures only one piece of code can run at a time:

const program = Effect.gen(function* () {
  // ...

  const semaphore = yield* Effect.makeSemaphore(1);

  const refresh = Effect.gen(function* () {
    const tokens = yield* Ref.get(accessTokensRef);

    const response = yield* httpClient.post("...", {
      body: HttpBody.unsafeJson({
        refreshToken: tokens.refreshToken
      })
    });

    const json = yield* response.json;

    const result = yield* RefreshResult.decodeUnknown(json);

    yield* Ref.update(accessTokensRef, () => ({
      accessToken: result.newAccessToken,
      refreshToken: result.newRefreshToken
    }));
  }).pipe(semaphore.withPermits(1));
});

All it takes is two lines of code, and just like that, we’ve guaranteed that our refresh operation runs exclusively - only one refresh can happen at a time, no matter how many concurrent requests we receive.

Implementing a Notification Mechanism

However, there’s a major issue in our current implementation. While requests are being queued up during a session refresh, nothing prevents each of them from triggering their own refresh once they reach the front of the queue. So, after the first refresh completes, the next request would trigger another refresh, and so on. We also lack a mechanism to tell the initiators to retry their original operation after a successful refresh.

To solve this, we’ll use two powerful features:

  1. Actionable errors - these let both the initial request, and any queued requests know when they should retry their original operations
  2. Effect’s DateTime module - a built-in alternative to libraries like luxon and momentjs

The plan is simple: when we successfully refresh the session, we will expose a ForceRetryError allowing the initiator to retry the operation, and we’ll also keep track of when we last refreshed the session in a shared reference. When a queued request tries to refresh the session, we’ll check if it was recently refreshed - if so, we’ll fail with the same ForceRetryError telling the request to try again:

import { HttpBody, HttpClient } from "@effect/platform";
import { Data, DateTime, Effect, Option, Ref, Schema } from "effect";

// ...

class ForceRetryError extends Data.TaggedError("ForceRetryError") {}

const program = Effect.gen(function* () {
  // ...

  const timeSinceLastRefresh = yield* Ref.make<Option.Option<DateTime.DateTime>>(Option.none());

  const refresh = Effect.gen(function* () {
    const now = yield* DateTime.now;
    const hasRecentlyRefreshed = Option.match(yield* timeSinceLastRefresh, {
      onSome: DateTime.greaterThan(DateTime.subtract(now, { minutes: 5 })),
      onNone: () => false
    });
    if (hasRecentlyRefreshed) return yield* new ForceRetryError();

    const tokens = yield* Ref.get(accessTokensRef);

    const response = yield* httpClient.post("...", {
      body: HttpBody.unsafeJson({
        refreshToken: tokens.refreshToken
      })
    });

    const json = yield* response.json;

    const result = yield* RefreshResult.decodeUnknown(json);

    yield* Ref.update(accessTokensRef, () => ({
      accessToken: result.newAccessToken,
      refreshToken: result.newRefreshToken
    }));
    return yield* new ForceRetryError();
  }).pipe(semaphore.withPermits(1));
});

Now, with this, our refresh computation has the following type signature:

const refresh: Effect.Effect<never, ParseError | ForceRetryError | HttpClientError, Scope>;

This tells us our computation can fail with three types of errors:

You’ll also notice our use of Option here - it helps us write more declarative code through its Option.match method, making our intentions a little clearer.

Implementing Timeout & Retry Policies

Now, let’s tackle the next requirements from our exercise: adding a 5-second timeout for session refreshes and implementing retries with exponential backoff (except for 401s and parse errors).

With Effect, it’s surprisingly simple; all you need is Effect.timeout and Effect.retry, and you’re good to go:

const program = Effect.gen(function* () {
  // ...

  const refresh = Effect.gen(function* () {
    // ...

    const response = yield* httpClient.post("...", {
      body: HttpBody.unsafeJson({
        refreshToken: tokens.refreshToken
      })
    });

    const json = yield* response.json;

    const result = yield* RefreshResult.decodeUnknown(json).pipe(Effect.orDie);

    yield* Ref.update(accessTokensRef, () => ({
      accessToken: result.newAccessToken,
      refreshToken: result.newRefreshToken
    }));

    return yield* new ForceRetryError();
  }).pipe(
    Effect.catchIf(
      (error) => error._tag === "ResponseError" && error.response.status === 401,
      () => Effect.dieMessage("Definitely not authenticated")
    ),
    Effect.timeout("5 seconds"),
    Effect.retry({
      times: 3,
      schedule: Schedule.exponential("1.5 seconds", 1.5),
      while: (error) => error._tag !== "ForceRetryError"
    }),
    Effect.catchAll((error) => (error._tag !== "ForceRetryError" ? Effect.die(error) : error)),
    semaphore.withPermits(1)
  );
});

Notice our liberal use of die throughout the implementation:

This systematic use of die reflects our error handling strategy: once we’ve exhausted our recovery mechanisms, we convert recoverable errors into terminal defects.

Now, for this, you need to understand that there are two fundamental types of errors:

In our implementation, we’ve decided that ForceRetryError should be the only expected error exposed to consumers. You might wonder why we don’t expose other errors like RequestError, ResponseError, or ParseError. Here’s my reasoning: errors ultimately fall into two categories - actionable and non-actionable.

Since we know defects are inherently non-actionable, we often need to transform initially actionable errors into non-actionable ones:

My advice is to be selective about exposing errors. Only expose truly actionable errors, and don’t hesitate to use die. As long as you have proper telemetry set up (which Effect makes remarkably easy), this approach is very maintainable.

Implementing the Latch

All that’s left (outside of the bonus requirements) is to queue all concurrent requests if a session refresh is in progress, to prevent making unnecessary requests that we know will fail with a 401. Now, I’m not talking about the semaphore we just implemented - that one works perfectly for when concurrent operations get a 401 and need to refresh the session.

I’m talking about preventing requests when a refresh operation is already in progress.

For this, we can use Effect.makeLatch. If you’re unfamiliar with latches, they are simple mechanisms that either block or allow operations to proceed based on whether they’re open or closed. They are similar to semaphores, except that you explicitly choose which operations to protect with whenOpen, and any operation can close the latch to block those protected operations:

const program = Effect.gen(function* () {
  const accessTokensRef = yield* Ref.make(
    AuthTokens.make({
      accessToken: "access-token",
      refreshToken: "refresh-token"
    })
  );
  const tokensLatch = yield* Effect.makeLatch(true);
  const getTokens = tokensLatch.whenOpen(Ref.get(accessTokensRef));
  const refresh = Effect.gen(function* () {
    // ...
    if (hasRecentlyRefreshed) return yield* new ForceRetryError();

    yield* tokensLatch.close;

    const tokens = yield* Ref.get(accessTokensRef);

    // ...
  }).pipe(
    // ...
    Effect.ensuring(tokensLatch.open),
    semaphore.withPermits(1)
  );

  const makeRequest = Effect.gen(function* () {
    const tokens = yield* getTokens;

    const response = yield* httpClient.get("...", {
      headers: { Authorization: `Bearer: ${tokens.accessToken}` }
    });

    // ...
  });
});

Notice how we create a new latch with Effect.makeLatch(true) - the true indicates it starts in an open state. Then, we protect our Ref.get(accessTokensRef) operation with whenOpen, which means any time we try to read tokens, this operation will be queued if the latch is closed. In the refresh operation, we immediately close the latch with tokensLatch.close, and we use Effect.ensuring to guarantee that tokensLatch.open is executed even if the parent computation fails with a defect or error.

Bonus Implementation

Now, we’re pretty much done! We have two final tasks:

  1. Add spans for telemetry
  2. Make our solution injectable as a service (including a custom HttpClient, similar to an Axios client)

Adding spans is beautifully simple in Effect. We just use Effect.withSpan("...") and Effect’s runtime automatically manages the parent-child relationships:

const refresh = Effect.gen(function* () {}).pipe(
  // ...
  Effect.withSpan("refresh")
);

And since HttpClient already adds spans for requests internally, that’s all we need for telemetry!

Now, for dependency injection, we’ll use Effect.Service to create our injectable services:

class Session extends Effect.Service<Session>()("Session", {
  effect: Effect.gen(function* () {
    const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);

    const accessTokensRef = yield* Ref.make(
      AuthTokens.make({
        accessToken: "access-token",
        refreshToken: "refresh-token"
      })
    );
    const tokensLatch = yield* Effect.makeLatch(true);
    const getTokens = tokensLatch.whenOpen(Ref.get(accessTokensRef));

    const semaphore = yield* Effect.makeSemaphore(1);

    const timeSinceLastRefresh = yield* Ref.make<Option.Option<DateTime.DateTime>>(Option.none());

    const refreshSession = Effect.gen(function* () {
      // ...
    }).pipe(
      // ...
      Effect.ensuring(tokensLatch.open),
      Effect.withSpan("refresh"),
      semaphore.withPermits(1)
    );

    return {
      refreshSession,
      getTokens
    };
  }),
  dependencies: [FetchHttpClient.layer]
}) {}

class CustomHttpClient extends Effect.Service<CustomHttpClient>()("CustomHttpClient", {
  effect: Effect.gen(function* () {
    const session = yield* Session;

    return (yield* HttpClient.HttpClient).pipe(
      HttpClient.filterStatusOk,
      HttpClient.mapRequestEffect((request) =>
        Effect.gen(function* () {
          const tokens = yield* session.getTokens;

          return request.pipe(
            HttpClientRequest.bearerToken(tokens.accessToken),
            HttpClientRequest.prependUrl("https://...")
          );
        })
      ),
      HttpClient.catchTags({
        ResponseError: (error) =>
          error.response.status === 401 ? session.refreshSession : Effect.fail(error)
      }),
      HttpClient.retry({
        times: 1,
        while: (error) => error._tag === "ForceRetryError"
      }),
      HttpClient.catchTags({
        ForceRetryError: Effect.die
      })
    );
  }),
  dependencies: [FetchHttpClient.layer, Session.Default]
}) {}

Notice our retry strategy here: we only retry once on ForceRetryError. Why? Because this error only occurs after a successful refresh, telling us to retry our original operation with the new token. If we get this error again after retrying (which would exhaust our single retry attempt), something has gone seriously wrong - either our refresh mechanism is broken, or we’re stuck in some kind of loop. In such cases, we convert the ForceRetryError into a defect using Effect.die, as this represents a situation that shouldn’t occur in a properly functioning system. In practice, you would typically model this as a 500 error on your server, or if you’re on the client-side, you’d likely log out the user since they’re definitely not authenticated anymore, discarding any lingering defects.

And that’s it! Using our implementation is as simple as consuming the service - all requests will automatically handle session refreshes on 401s and retry the original operation:

const program = Effect.gen(function* () {
  const httpClient = yield* CustomHttpClient;

  return yield* httpClient.get("...");
});

program.pipe(Effect.provide(CustomHttpClient.Default), Effect.scoped, Effect.runPromise);

All in around 100 lines of code!

Conclusion

What I love about Effect is how it lets me focus on business logic instead of getting lost in implementation details. Without Effect, this same solution would easily span 400+ lines of code, and its intent wouldn’t be nearly as clear at first glance.

In the end, it’s all about the scope of the project. If what you’re doing is a simple CLI, a basic front-end, simple authenticated APIs acting as proxies, or whatever along these lines, Effect can be “overkill”.

But here’s the thing: every project starts out with the mindset of “We don’t need X, we’ll keep it simple.” But as the project grows, requirements inevitably expand, edge cases multiply, and what was once “simple” can quickly become a tangled mess.

Before you know it, you’re reinventing the wheel, cobbling together hacky solutions for problems that more robust frameworks or libraries have already solved elegantly, and suddenly, that “overkill” technology doesn’t seem so excessive anymore.

If you’re still on the fence on whether to use Effect or not, give it a try! Spend a weekend playing around with it - what’s the worst that could happen? :)

Full implementation: https://effect.website/play#f020d2cf277e