The Problem Effect Solves
TypeScript has excellent type checking for the happy path. But try/catch gives you an unknown error type — the failure path is completely untyped. And dependencies (database clients, HTTP clients, config) are typically passed through function arguments or grabbed from globals — invisible to the type system.
Effect-TS solves both. Every Effect has three type parameters: Effect<Success, Error, Requirements>. The compiler tracks what can go wrong and what is needed to run.
The Effect Type
import { Effect } from "effect";
// Effect<User, UserNotFound | DatabaseError, DatabaseClient>
// Success: User
// Error: UserNotFound | DatabaseError
// Requirements: DatabaseClient
function getUser(id: string): Effect.Effect<User, UserNotFound | DatabaseError, DatabaseClient> {
return Effect.gen(function* () {
const db = yield* DatabaseClient;
const user = yield* db.findUser(id);
if (!user) yield* Effect.fail(new UserNotFound({ id }));
return user;
});
}
Effect.gen() — Async/Await Syntax
Effect.gen() provides generator-based syntax that reads like async/await but with typed errors:
import { Effect, Console } from "effect";
const program = Effect.gen(function* () {
const user = yield* getUser("user-123"); // typed: User | fails with UserNotFound
const posts = yield* getUserPosts(user.id); // typed: Post[] | fails with NotFound
yield* Console.log(`${user.name} has ${posts.length} posts`);
return { user, posts };
});
// Run it — provide requirements, handle errors
Effect.runPromise(
program.pipe(
Effect.provide(DatabaseLive), // satisfy DatabaseClient requirement
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed({ user: null, posts: [] })
),
)
);
Typed Error Channel
import { Data, Effect } from "effect";
// Define typed errors
class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {}
class DatabaseError extends Data.TaggedError("DatabaseError")<{ cause: unknown }> {}
const fetchUser = (id: string) =>
Effect.tryPromise({
try: () => db.users.findOne(id),
catch: (e) => new DatabaseError({ cause: e }),
}).pipe(
Effect.flatMap((user) =>
user ? Effect.succeed(user) : Effect.fail(new UserNotFound({ id }))
)
);
// Handle specific error types
fetchUser("123").pipe(
Effect.catchTag("UserNotFound", () => Effect.succeed(defaultUser)),
Effect.catchTag("DatabaseError", (e) => {
console.error(e.cause);
return Effect.fail(new ServiceUnavailable());
})
);
Dependency Injection via Layer
import { Context, Layer, Effect } from "effect";
// Define a service interface
class EmailService extends Context.Tag("EmailService")<
EmailService,
{ sendWelcome: (email: string) => Effect.Effect<void> }
>() {}
// Live implementation
const EmailServiceLive = Layer.succeed(
EmailService,
{
sendWelcome: (email) =>
Effect.tryPromise({
try: () => nodemailer.sendMail({ to: email, subject: "Welcome!" }),
catch: (e) => new EmailError({ cause: e }),
}),
}
);
// Test implementation
const EmailServiceTest = Layer.succeed(
EmailService,
{ sendWelcome: (_email) => Effect.void }
);
// Use in program — compiler requires EmailService to be provided
const registerUser = (email: string) =>
Effect.gen(function* () {
const mailer = yield* EmailService;
yield* mailer.sendWelcome(email);
});
Retry, Timeout, and Schedule
import { Effect, Schedule } from "effect";
const resilientFetch = fetchUser("123").pipe(
Effect.timeout("5 seconds"),
Effect.retry(
Schedule.exponential("100 millis").pipe(
Schedule.intersect(Schedule.recurs(3)) // max 3 retries with exponential backoff
)
),
);
When to Adopt Effect-TS
Good fit:
- New TypeScript projects where you control the whole stack
- Server-side Node.js code with complex error scenarios
- Replacing fp-ts (Effect is the spiritual successor)
- Teams that want to eliminate runtime surprises
Not the right fit:
- Browser bundles (Effect adds ~50KB gzipped)
- Projects mixing plain JS and TypeScript
- When you just need simple error handling — use neverthrow instead