Introduction to Effect-TS: Typed Functional Error Handling and Side Effects
AI generated
type
TypeScript · Functional Programming
Introduction to Effect-TS
Typed functional error handling and side effects

With the type Effect, Effect-TS surfaces what try-catch hides: which errors an operation can throw and which dependencies it needs to run. Both become part of the signature, not just a footnote in the documentation.

11 min read Effect-TS Error handling Functional programming

1. The problem try-catch does not solve

In TypeScript, a function using throw can throw any error type without that showing up anywhere in its signature. The return type Promise promises a user but reveals nothing about whether a network error, a validation error or a database exception might occur along the way. Callers have to rely on documentation or reading the source to know the possible failure modes.

Effect solves this by making the error type part of the signature itself. An operation of type Effect describes a computation plan that, on success, produces a value of type A, on failure produces a typed error of type E, and requires an environment of type R to run. The compiler checks all three aspects, none of them stays implicit.


import { Effect } from 'effect';

class UserNotFoundError {
  readonly _tag = 'UserNotFoundError';
  constructor(readonly id: string) {}
}

function findUser(id: string): Effect.Effect<User, UserNotFoundError> {
  return Effect.gen(function* () {
    const user = await lookupUser(id);
    if (!user) {
      return yield* Effect.fail(new UserNotFoundError(id));
    }
    return user;
  });
}

2. The three type parameters of Effect, in detail

The first type parameter A corresponds to the success value, comparable to the type parameter of a Promise. The second parameter E describes every possible, expected error case as a union type, which lets error handling at the call site be checked completely and exhaustively, similar to a Result type, only propagated automatically.

The third parameter R describes dependencies required to run the effect, for example a database client or a logger. An effect with R = never is fully self contained and runnable, while an effect with a concrete R type only runs once that dependency has been supplied. This mechanism replaces classic dependency injection via constructor parameters with a typed environment channel.

3. Effect.gen: sequential code without a pyramid of pipe calls

Effects can be chained through combinators like Effect.flatMap and Effect.map with pipe(), which quickly gets unwieldy across several consecutive steps. Effect.gen solves this by using generator functions to write sequential, imperative looking code that internally still consists entirely of effects.

Inside a generator, yield* marks every point where an effect is run and its result unwrapped, comparable to await for promises. If one of the steps fails, the entire generator function aborts with the corresponding typed error, with no explicit intermediate checks required.


import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const user = yield* findUser('u1');
  const posts = yield* findPostsByAuthor(user.id);
  const summary = yield* summarizePosts(posts);
  return { user, summary };
});

4. Handling the error channel selectively

To react to typed errors, Effect offers functions like Effect.catchTag, which react to a specific error type based on a discriminant field, usually _tag, while other error types pass through unchanged. This is the equivalent of a type-safe switch over the error union, only without manual pattern matching in caller code.

For cases where an error can actually be recovered from and the flow continued, catchTag returns a new effect whose error channel has been reduced by the handled error type. That lets the compiler recognize exactly which error cases remain open after handling and which have already been covered.


const safeProgram = program.pipe(
  Effect.catchTag('UserNotFoundError', (error) =>
    Effect.succeed({ user: null, summary: `No user with ID ${error.id}` }),
  ),
);

5. Layer: typed dependency injection without a container

Dependencies in Effect get declared through Context.Tag, a unique token for a service such as a database connection, and get concretely supplied through Layer values. An effect that requests a dependency via yield* DatabaseService automatically carries that requirement in its third type parameter R, so the compiler enforces that a matching implementation is supplied before the effect can actually run.

Layers can be composed, letting a complex application be assembled from many small, independently testable services. For tests, the real DatabaseLayer gets swapped out for a TestDatabaseLayer with an in memory implementation, without touching the business logic itself.


import { Context, Effect, Layer } from 'effect';

class DatabaseService extends Context.Tag('DatabaseService')<
  DatabaseService,
  { findUser: (id: string) => Effect.Effect<User | null> }
>() {}

const DatabaseLive = Layer.succeed(DatabaseService, {
  findUser: (id) => Effect.promise(() => db.user.findUnique({ where: { id } })),
});

const program = Effect.gen(function* () {
  const db = yield* DatabaseService;
  return yield* db.findUser('u1');
});

Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));

6. The Schema module: validation with the same error model

Effect ships its own validation library through the Schema module, which fits seamlessly into the same effect based error model instead of treating an external library like Zod as a separate concern. A Schema.decodeUnknown call returns an effect whose error channel contains ParseError, which can be handled with catchTag or Effect.gen exactly like any other typed error.

The advantage over a separate validation library lies in unified error handling: a validation error, a database error and a network error all end up in the same structured error channel and can be handled with the same combinators, instead of mixing three different error models in the same program.


import { Schema, Effect } from 'effect';

const UserInput = Schema.Struct({
  email: Schema.String.pipe(Schema.pattern(/^\S+@\S+$/)),
  name: Schema.String.pipe(Schema.minLength(1)),
});

const parseUserInput = (raw: unknown) => Schema.decodeUnknown(UserInput)(raw);

7. Retry and timeout as typed combinators

Resilience against transient failures in Effect is not achieved through nested try-catch loops, but through declarative combinators like Effect.retry and Effect.timeout, applied to an existing effect. A retry policy precisely describes how many times and with what backoff behavior a retry should happen.

Because these combinators themselves return another effect, they can be freely combined with other effects, for example a database query that gets automatically cancelled after a timeout and retried up to three times with exponential backoff, entirely declaratively with no nested loop logic.


import { Effect, Schedule, Duration } from 'effect';

const resilientFetch = fetchRemoteData.pipe(
  Effect.timeout(Duration.seconds(5)),
  Effect.retry(Schedule.exponential(Duration.millis(200)).pipe(Schedule.compose(Schedule.recurs(3)))),
);

8. Effect compared to lightweight Result libraries

Lightweight libraries such as neverthrow express a similar core idea with a Result type, typed errors instead of thrown exceptions, but deliberately stay limited to synchronous and asynchronous value computation. Effect goes considerably further, additionally covering side effect management, concurrency via fibers, dependency injection and resource management.

For smaller projects or a gradual migration, a lightweight Result type is often enough and involves less of a learning curve. Effect pays off once an application already needs complex concurrency, layered dependencies or sophisticated error handling strategies that would otherwise have to be rebuilt by hand.

9. Running effects: from description to actual effect

An Effect value is at first only a description of a computation, not an already executed action. Only functions such as Effect.runPromise, Effect.runSync or Effect.runFork trigger the actual execution, each fitting the context in which the result is needed, for example as a promise at the edge of a Node.js application or synchronously inside an already running context.

This strict separation between description and execution allows effects to be composed, tested and reused like ordinary values, before any side effect ever occurs. That differs fundamentally from an async function, which starts running immediately on every call, regardless of whether its result is actually needed.

Aspect try-catch Result type (e.g. neverthrow) Effect-TS
Error type in signature Not visible Explicit as a type parameter Explicit as second type parameter E
Typed dependencies No No Yes, via third type parameter R
Concurrency Manual with Promise.all Manual with Promise.all Built in fiber based concurrency
Retry/timeout Handwritten loops Handwritten loops Declarative combinators
Learning curve Low Low to medium High, its own mental model

Mironsoft

TypeScript migration, type safety, and team onboarding

A JavaScript codebase without type safety, but no time for a full migration?

We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.

Migration Roadmap

Plan and execute a gradual JS-to-TS migration without big-bang risk.

Strict Mode Rollout

Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.

Team Onboarding

Bring developers up to speed on TypeScript best practices with workshops and reviews.

10. Summary

Introduction to Effect-TS

Core type

Effect makes success, error and dependency visible

Sequential code

Effect.gen with yield* feels like async/await, stays typed

Dependency injection

Context.Tag and Layer replace manual constructor injection

Resilience

Effect.retry and Effect.timeout as declarative combinators

11. FAQ: Introduction to Effect-TS

1What does Effect-TS solve that try-catch cannot?
try-catch reveals nothing about a function's possible error types, they stay implicit. Effect makes errors explicitly visible and compiler checked through the second type parameter E of an effect signature.
2What does the third type parameter R stand for in Effect?
R describes the dependencies needed to run the effect, for example a database client. An effect with R = never is fully self contained and runnable, an effect with a concrete R type needs a supplied implementation.
3What does Effect.gen do differently from chained pipe calls?
Effect.gen uses generator functions to write sequential, imperative looking code. yield* unwraps every effect step, comparable to await for promises, while staying fully typed.
4How do you handle a specific error type selectively?
With Effect.catchTag, which reacts to a specific error type based on a discriminant field like _tag. The compiler then recognizes which error cases in the error channel have already been handled.
5What is a Layer in Effect-TS?
A Layer supplies a concrete implementation for a service declared through Context.Tag. Layers can be composed and swapped out for tests with alternative implementations, without touching the business logic.
6Does the Schema module replace an external validation library like Zod?
Effect ships its own validation library with Schema, whose errors flow into the same effect error channel as other errors in the program. Zod remains a valid, standalone choice outside Effect projects.
7How does retry logic work in Effect without loops?
Through declarative combinators like Effect.retry combined with a Schedule policy that describes the number of attempts and backoff behavior. The result is itself another effect that can be combined further.
8When does Effect-TS pay off over a lightweight Result library?
Once an application needs, beyond typed errors, complex concurrency, layered dependencies or declarative resilience strategies like retry and timeout that would otherwise have to be rebuilt manually.
9Does an effect run immediately once it is created?
No, an effect value is at first only a description of a computation. Only functions like Effect.runPromise or Effect.runSync trigger the actual execution.
10Is Effect-TS compatible with existing promise based code?
Yes, existing promises can be turned into an effect via Effect.promise and Effect.tryPromise, and Effect.runPromise returns a normal promise again at the edge of the application.