โ€ข

tech

Effect to the rescue

Many are introduced to effect for error handling and dependency injection. Those are huge wins, but effect goes way beyond that. Let me show you a couple of concrete examples.


Sandro Maglione

Sandro Maglione

Software

You may think you don't need effect until... ๐Ÿคฏ

This is the story of many problems my app hit over time, and how effect solved them one after the other with no effort ๐Ÿ‘‡


Slow AI: Streaming

For the initial MVP, it was easier to just make a "normal" API call, wait, and get back the full AI response.

That was already easy enough with @effect/ai, using the generateObject API ๐Ÿช„

But then the prompt became more complex, and with it increased also the waiting time and user frustration.

Let's move to streaming then. Sounds complex? Well, it's mostly a single function change: from generateObject to streamText ๐Ÿ’๐Ÿผโ€โ™‚๏ธ

You get an effect Stream, and the Stream API has all sort of mapping/filtering already available for you ๐Ÿช„

Bonus: NDJSON

With streaming, a full structured JSON response doesn't fit anymore.

I want to parse/send an array of responses one by one, instead of waiting for the full JSON to complete ๐Ÿค”

That's what NDJSON is for (Newline Delimited JSON).

I initially implemented my own Stream solution to parse NDJSON outputs, before effect solved this for me as well with the Ndjson module (@effect/platform).

One line change, and all works ๐Ÿ’ฏ

Scope and timeouts

I was integrating the Posthog API (analytics). The SDK requires calling shutdown to clean resources.

Easy with effect: using Scope and acquireRelease โœจ

export class Posthog extends Effect.Service<Posthog>()("Posthog", {
  scoped: Effect.gen(function* () {
    const { apiKey, host } = yield* Config.all({
      apiKey: Config.redacted("POSTHOG_API_KEY"),
      host: Config.string("POSTHOG_API_HOST"),
    });

    const client = yield* Effect.acquireRelease(
      Effect.sync(
        () => new _PostHog(Redacted.value(apiKey), { host })
      ),
      (client) => Effect.promise(() => client.shutdown())
    );
    
    // ...
  }),
}) {}

But then I noticed another problem, what if shutdown itself gets stuck? ๐Ÿค”

It theory it should be fast, but in practice you want to timeout if it takes too long for whatever reason ๐Ÿคท๐Ÿผโ€โ™‚๏ธ

Again, one line change with effect and we are done, using Effect.timeout:

export class Posthog extends Effect.Service<Posthog>()("Posthog", {
  scoped: Effect.gen(function* () {
    const { apiKey, host } = yield* Config.all({
      apiKey: Config.redacted("POSTHOG_API_KEY"),
      host: Config.string("POSTHOG_API_HOST"),
    });

    const client = yield* Effect.acquireRelease(
      Effect.sync(
        () => new _PostHog(Redacted.value(apiKey), { host })
      ),
      (client) => 
        Effect.promise(() => client.shutdown()).pipe(
          Effect.timeout("2 seconds"),
          Effect.ignore
        )
    );
    
    // ...
  }),
}) {}

Caching

I am using the beforeLoad API of TanStack Router to verify the user status.

beforeLoad is called on each Link hover event, all the times, so it must be fast โšก๏ธ

My beforeLoad was performing an API request, all the times, every time the same, for all hover events. Not good.

That's where caching comes in. And again, with effect is a one line change with withRequestCaching:

export const Route = createFileRoute("/_authenticated/_dashboard")({
  beforeLoad: async ({ context }) => {
    const { status } = await RuntimeClient.runPromise(
      Effect.gen(function* () {
        const apiRequest = yield* ApiRequest;
        const accessToken = yield* Auth.getToken(context.auth.getToken);
        return yield* Effect.request(
          new GetAccountStatus({ token: accessToken }),
          apiRequest.getAccountStatus
        ).pipe(
          Effect.withRequestCaching(true)
        );
      }).pipe(
        Effect.catchAll(() => Effect.dieMessage("Failed to get account status"))
      )
    );
    
    // ...
  },
// ...

Queue events

The Paddle client SDK (payments) provides a callback function to listen for events ๐Ÿซ 

I want to extract those events and pipe them to an xstate actor ๐Ÿค”

Well, effect ๐Ÿ’๐Ÿผโ€โ™‚๏ธ

With the Queue module I can push events to a queue, and then use Stream to send them to xstate:

export class Paddle extends Effect.Service<Paddle>()("Paddle", {
  effect: Effect.gen(function* () {
    const runtime = yield* Effect.runtime();
    const runPromise = Runtime.runPromise(runtime);

    const eventQueue = yield* Queue.unbounded<PaddleEventData>();

    const eventCallback = (event: PaddleEventData) => {
      void runPromise(Queue.offer(eventQueue, event));
    };

    const paddle = yield* Effect.promise(() =>
      initializePaddle({ token, environment: "sandbox", eventCallback })
    );

    return {
      eventStream: Stream.fromQueue(eventQueue),
      // ...
    };
  }),
}) {}

Stream.fromQueue gives me back a Stream, and I once again can use all the Stream API to send events to the xstate state machine ๐Ÿช„


These are just a few examples. It's a testimony of how effect goes beyond error handling and dependency injection (which are huge wins already).

So, you probably do need effect after all ๐Ÿค

See you next ๐Ÿ‘‹

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it