Typed functional error handling and side effects
Table of Contents
- 1. The problem try-catch does not solve
- 2. The three type parameters of Effect, in detail
- 3. Effect.gen: sequential code without a pyramid of pipe calls
- 4. Handling the error channel selectively
- 5. Layer: typed dependency injection without a container
- 6. The Schema module: validation with the same error model
- 7. Retry and timeout as typed combinators
- 8. Effect compared to lightweight Result libraries
- 9. Running effects: from description to actual effect
- 10. Summary
- 11. FAQ
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
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