Getting started with Effect

A practical guide to TypeScript’s missing standard library

We’ve now reached the endgame of our discussion of Result libraries: introducing Effect!

When first getting started with Effect, there are a few key concepts that can take some time to wrap your head around:

  1. Functional programming - a different way of composing operations
  2. Generator syntax - a way to write sequential Effect code
  3. Lazy evaluation - how Effect handles when code actually executes

Let’s explore these concepts below.

Introducing the Effect type

Like the Result types we explored in previous posts, Effect contains both a value and an error component. It also has a third component called requirements, which we’ll explore in a later post (for now, it will always be set to never). These three components are displayed in that order on the Effect type when you construct one. We can imagine this Effect.Effect<T, E, R> type to be equivalent to a Result<T, E>

// `Effect.succeed` like `Result.Ok` method
// Effect.Effect<string, never, never>
const a = Effect.succeed("a");

// `Effect.fail` like `Result.Error` method
// Effect.Effect<never, number, never>
const b = Effect.fail(9);

// Effect.Effect<number, "Negative number", never>
const parse = (input: string) => {
  const num = parseInt(input);

  if (num < 0) {
    return Effect.fail("Negative number" as const);
  }
  return Effect.succeed(num);
};

Syntax

Effect draws from a functional programming background. Instead of traditional method chaining, it uses a pipe function for composition.

// Boxed example
const fetchUser = (id: string): Future<User, string> =>
  Future.fromPromise(
    fetch(`/api/users/${id}`).then((res) => res.json())
  ).mapError(() => "Failed to fetch user");

// Effect example
const fetchUser = (id: string): Effect.Effect<User, string, never> =>
  Effect.tryPromise(() =>
    fetch(`/api/users/${id}`).then((res) => res.json())
  ).pipe(Effect.mapError(() => "Failed to fetch user"));

Although it’s more verbose, this consistency means once you learn the pattern, you can compose any Effect operations together without memorizing different APIs for each utility (retries, timeouts, caching, etc).

The functional programming approach with pipe means you can simplify your function calls that have multiple arguments and read them in a logical top-down way.

// Effect.Effect<string[], never, never>
const users = Effect.succeed(["user1", "user2", "user3"]);

// Instead of:
Effect.forEach(users, (id) => fetchUser(id));

// You can use the curried version:
users.pipe(Effect.forEach(fetchUser));

This works with any Effect function that takes multiple arguments:

// ❌ Before: Confusing order of operations
const robustFetch = Effect.withSpan(
  Effect.retry(Effect.timeout(fetchUser("123"), "3 seconds"), {
    times: 3,
    schedule: Schedule.exponential("1 second"),
  }),
  "fetchUser"
);

// ✅ After: More explicit and readable
const robustFetch = fetchUser("123").pipe(
  Effect.timeout("3 seconds"),
  Effect.retry({
    times: 3,
    schedule: Schedule.exponential("1 second"),
  }),
  Effect.withSpan("fetchUser")
);

In addition, Effect provides a syntax that is similar to async/await, which can be helpful when andThen chaining starts to get unwieldy. To enable this, create a function with Effect.gen. We can imagine function*/yield* as a direct equivalent to async/await. 1

// Instead of heavy chaining:
const welcomeUserPipe = (userId: string) =>
  fetchUser(userId).pipe(
    Effect.andThen((user) =>
      user.email ? Effect.succeed(user) : Effect.fail("User has no email")
    ),
    Effect.andThen((user) =>
      sendEmail(user.email, "Welcome!").pipe(
        Effect.map(() => `Welcome email sent to ${user.email}`)
      )
    )
  );

// You can use generator syntax:
const welcomeUserGen = (userId: string) =>
  Effect.gen(function* () {
    const user = yield* fetchUser(userId);

    if (!user.email) {
      return yield* Effect.fail("User has no email");
    }

    yield* sendEmail(user.email, "Welcome!");
    return `Welcome email sent to ${user.email}`;
  });

Box vs blueprint

Effect takes the Result concept and builds upon it with one subtle change: lazy execution. If Result is a box that already contains either a success value or an error, then Effect is a blueprint for creating that box: a description of work that could succeed, fail, or need additional context, but hasn’t run yet.

When you are ready to execute, you call one of Effect’s run methods, most likely runPromise. 2

This lazy execution is the key insight. With regular Result types, the computation happens immediately:

// The database call happens immediately
const userResult: Result<User, DatabaseError> = safeFetchUserFromDb(userId);

But with Effect, you’re building a description of what should happen:

// This describes what will happen, but doesn't run yet
const userEffect: Effect.Effect<User, DatabaseError, never> =
  effectFetchUserFromDb(userId);

// The database call only happens when you run the Effect using one of its `.run[X]` methods
Effect.runPromise(userEffect);

The blueprint advantage

Because Effect is a blueprint, you can build entire pipelines of functionality before running a single line of actual computation. Let’s say we have the following function:

// Basic building blocks
const fetchUser = (id: string) =>
  Effect.tryPromise(() => fetch(`/api/users/${id}`).then((r) => r.json()));

We can augment the fetch function like so:

// Compose blocks into a more complex block
const robustUserFetch = (userId: string) =>
  fetchUser(userId).pipe(
    Effect.retry({ times: 3 }),
    Effect.timeout("5 seconds"),
    Effect.catchAll(() => Effect.succeed({ email: "default@example.com" }))
  );

Thanks to the declarative nature of functional programming, we now have a more powerful and easily readable blueprint of fetching functionality that we can compose with other blueprints.

const validateUser = (user: object) =>
  "email" in user ? Effect.succeed(user) : Effect.fail("No email");

const sendEmail = (email: string) =>
  Effect.tryPromise(() => emailService.send(email, "Welcome!"));

// Compose that block with others to build an even higher-level workflow
const welcomeUserWorkflow = (userId: string) =>
  robustUserFetch(userId).pipe(
    Effect.andThen(validateUser),
    Effect.andThen((user) => sendEmail(user.email)),
    Effect.catchAll((error) => Effect.succeed(`Fallback: ${error}`))
  );

// Still just a blueprint! Nothing has executed yet.
// The actual computation only starts when we call Effect.runPromise()

Think of it like Lego blocks. The basic operations (fetchUser, validateUser, sendEmail) are your fundamental bricks. You snap them together with Effect combinators (pipe, andThen, catchAll) to build more specialized blocks (robustUserFetch). Then you can use those specialized blocks as single units to build even more complex structures (welcomeUserWorkflow). Each level of abstraction becomes a reusable building block for the next level. This gets even more powerful when we dig into the “requirements” part of the Effect type.

Common Effect anti-patterns to avoid

In my experience working with Effect, I’ve noticed several common pitfalls that developers tend to run into when first getting started:

Running Effects too early

One of the most common mistakes when adopting Effect is converting back and forth between Promises and Effects within your application logic. This defeats the purpose of Effect’s composability and can lead to confusing error handling.

There’s no need to convert between Promise and Effect within Effect code. You only need to runPromise at the boundary of a package when you are ready to leave Effect-land.

// ❌ no need to convert back and forth
const bad = async () => {
  const foo = await effectFetch("example.com").pipe(Effect.runPromise);
  const bar = await effectJson(foo).pipe(Effect.runPromise);
  return bar;
};

// ✅ use pipe
const good = () =>
  effectFetch("example.com").pipe(
    Effect.andThen(effectJson),
    Effect.runPromise // only convert to Promise at the end
  );

// ✅ or use generator function
const good2 = async () =>
  Effect.gen(function* () {
    const foo = yield* effectFetch("example.com");
    const bar = yield* effectJson(foo);
    return bar;
  }).pipe(Effect.runPromise); // only convert to Promise at the end

Unclear use of custom errors

Here’s a common anti-pattern I see: creating elaborate error hierarchies and then immediately flattening them:

// ❌ Anti-pattern: Create many errors, then squash them all
const fetchUserData = (id: string) =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`),
    catch: () => new NetworkError(),
  }).pipe(
    Effect.andThen(
      Effect.tryPromise({
        try: () => validateResponse(),
        catch: () => new ValidationError(),
      })
    ),
    Effect.andThen(
      Effect.tryPromise({
        try: () => checkAuth(),
        catch: () => new AuthError(),
      })
    ),
    // Then immediately squash all that careful error differentiation!
    Effect.catchAll(() => Effect.succeed("Something went wrong")),
    Effect.runPromise
  );

Better approach: Only differentiate errors that the consumer actually needs to handle differently. If you only want to let some tags succeed and others to throw, catch them before runPromise so they don’t throw:

// ✅ Better: Distinguish recoverable vs non-recoverable errors
type UserFetchResult = User | "rate-limited" | "user-not-found";

const fetchUserData = (id: string): Effect.Effect<UserFetchResult, never> =>
  Effect.tryPromise(() => fetch(`/api/users/${id}`)).pipe(
    Effect.andThen(validateAndParseUser),
    Effect.catchTags({
      // These are recoverable - let consumer decide what to do
      RateLimitError: () => Effect.succeed("rate-limited" as const),
      NotFoundError: () => Effect.succeed("user-not-found" as const),
      // Everything else is truly an error that should bubble up
    }),
    Effect.runPromise
  );

// Consumer can handle meaningful cases
const result = await fetchUserData("123");
switch (result) {
  case "rate-limited":
    // Show "try again later" message
    break;
  case "user-not-found":
    // Redirect to signup flow
    break;
  default:
    // Use the actual user data
    break;
}

Squashing errors into success channel

As you might have noticed in my previous examples, when we convert from Effect-land back into Promise-land, we have to funnel errors into the T part of Effect<T, E, R> since the Promise will throw otherwise. Rather than using runPromise and then hackily shoving the errors through via Effect.succeed, you can use runPromiseExit and pattern match on the Exit type.

// { ok: true; value: Response; } | { ok: false; error: Cause<JsonError | OtherError>;}
const bar = await safeFetchJson(
  "https://pokeapi.co/api/v2/pokemon/blah",
  schema
)
  .pipe(Effect.runPromiseExit)
  .then(
    Exit.match({
      onSuccess: (v) => ({ ok: true as const, value: v }),
      onFailure: (e) => ({ ok: false as const, error: e }),
    })
  );

See my post on how to extract the original Error from a Cause.

But wait, there’s more!

While Effect provides a powerful Result abstraction, it’s actually much more than that. It aims to be TypeScript’s missing standard library. Rather than cobbling together various utility libraries or writing your own solutions, Effect gives you battle-tested implementations of common production patterns right out of the box.

Think about all the utility code you’ve written before: retry logic, timeout handling, concurrency control, and more. With Effect, these patterns are available as composable building blocks that work together seamlessly. Let’s look at some examples of what Effect can do beyond just Result types.

const safeFetch = (url: string) => Effect.tryPromise(() => fetch(url));

// --- Retries ---

Effect.retry(safeFetch("https://example.com"), {
  times: 3,
  schedule: Schedule.exponential("1 second"),
});

// --- Timeout ---

Effect.timeout(safeFetch("URL that could hang forever"), "3 seconds");

// --- Concurrency control ---

Effect.all(
  [
    safeFetch("https://example.com"),
    safeFetch("https://example.com"),
    safeFetch("https://example.com"),
    safeFetch("https://example.com"),
    safeFetch("https://example.com"),
  ],
  {
    concurrency: 2, // only 2 requests will run at a time
  }
);

// --- OpenTelemetry integration ---

safeFetch("example.com").pipe(
  Effect.withSpan("safeFetch", {
    attributes: {
      foo: "bar",
    },
  })
);

// --- DateTime and Duration utilities ---

const maybeUtc = DateTime.make(new Date());
const maybeUtc2 = DateTime.make("invalid date");

const month = maybeUtc.pipe(
  Option.map((utc) => DateTime.getPartUtc(utc, "day"))
);
// can also use curried version so you don't have to pass the first argument explicitly
const month2 = maybeUtc.pipe(Option.map(DateTime.getPartUtc("day")));

const maybeZoned = DateTime.makeZoned(new Date("2025-01-01 04:00:00"), {
  timeZone: "Europe/Rome",
});

const newZone = maybeZoned.pipe(
  Option.map((zoned) =>
    DateTime.setZone(zoned, DateTime.zoneUnsafeMakeNamed("America/New_York"))
  )
);

// --- Schema-based serialization ---

// has built-in JSON serialization and deserialization
const dateSchema = s.Struct({
  date: s.Date,
});

// automatically converts to string
const fromDate = s.encodeSync(dateSchema)({ date: new Date() });
// rehydrates back to Date
const toDate = s.decodeSync(dateSchema)(fromDate);

const userSchema = s.Struct({
  id: s.Number,
  name: s.String,
  email: s.Option(s.String), // preserved as Option across the client-server boundary
});

The benefit of the Effect ecosystem is that all of these utilities are designed to work together. This interoperability eliminates the integration headaches you’d face when combining separate libraries.

Miscellaneous gotchas

  • The Effect docs don’t have the entire API. You have to go to API reference pages for the complete picture
    • For example, Effect.allWith is only mentioned in the API page and is useful if you want to set concurrency within a pipe chain
  • Effect.all is sequential by default. You have to pass { concurrency: 'unbounded' | number } for parallel execution
  • Effect.andThen (ref) is the go-to combinator because it handles flatmapping + converting non-Effect values, but it won’t warn about errors if you use a sync (non-Promise) function (ref)
  • Don’t include any side effects inside Option generator functions:

    When using Option.gen, avoid including side effects in your generator functions, as Option should remain a pure data structure. - ref

  • Make sure that all effects within Effect.flatMap contribute to the final computation. If you ignore an effect, it can lead to unexpected behavior - ref

Business impact

For technical decision-makers, Effect consolidates multiple utility libraries into a single, interoperable system, significantly reducing your team’s dependency management overhead and onboarding complexity. By standardizing on Effect’s patterns for common tasks like retries, timeouts, and error handling, you eliminate the need for engineers to research and implement these patterns from scratch, reducing both development time and the risk of inconsistent implementations across your codebase.