Effect-TS — Typed Functional Programming for TypeScript
A practical guide to Effect-TS — what it solves, core concepts like Effect, Layer, and Runtime, comparison with fp-ts, and when the complexity is worth it. Includes real refactoring examples.
TypeScript's type system can tell you what a function returns, but it can't tell you what might go wrong, what dependencies are needed, or what side effects will run. You write async function getUser(id: string): Promise and the types say nothing about the database connection it needs, the network errors it might throw, or the fact that it logs to stdout.
Effect-TS fixes this. It encodes errors, dependencies, and async behavior into the type signature itself, making the compiler catch entire categories of bugs that TypeScript normally misses. The trade-off: it's a paradigm shift. Your code will look different. Your team needs to learn new concepts. Whether that's worth it depends on what you're building.
What Problems Effect Actually Solves
1. Errors That Types Don't Track
Standard TypeScript:
// What can go wrong? The types don't say.
async function getUser(id: string): Promise<User> {
const res = await fetch(/api/users/${id});
if (!res.ok) throw new HttpError(res.status); // invisible in types
const data = await res.json();
const user = userSchema.parse(data); // might throw ZodError
return user;
}
// Caller has no idea what to catch
try {
const user = await getUser("123");
} catch (e) {
// Is this HttpError? ZodError? TypeError? Network failure?
// TypeScript has no opinion.
}
With Effect:
import { Effect, pipe } from "effect";
class HttpError {
readonly _tag = "HttpError";
constructor(readonly status: number) {}
}
class ParseError {
readonly _tag = "ParseError";
constructor(readonly message: string) {}
}
// The type signature tells the full story:
// Effect<User, HttpError | ParseError, never>
// ↑ ↑ ↑
// success possible errors dependencies
function getUser(id: string): Effect.Effect<User, HttpError | ParseError> {
return pipe(
Effect.tryPromise({
try: () => fetch(/api/users/${id}),
catch: () => new HttpError(0),
}),
Effect.flatMap((res) =>
res.ok
? Effect.succeed(res)
: Effect.fail(new HttpError(res.status))
),
Effect.flatMap((res) =>
Effect.tryPromise({
try: () => res.json(),
catch: () => new ParseError("Invalid JSON"),
})
),
Effect.flatMap((data) =>
Effect.try({
try: () => userSchema.parse(data),
catch: (e) => new ParseError(String(e)),
})
),
);
}
Now the return type Effect is a contract. The compiler enforces that every caller handles both error types. You can't accidentally swallow an error or catch the wrong one.
2. Dependency Injection Without a Framework
Standard TypeScript approaches to DI usually involve a container (InversifyJS, tsyringe) with runtime reflection and decorators. Effect does it at the type level.
import { Effect, Context, Layer } from "effect";
// Define a service interface
class Database extends Context.Tag("Database")<
Database,
{
query: <T>(sql: string, params: unknown[]) => Effect.Effect<T[], DbError>;
}
>() {}
class Logger extends Context.Tag("Logger")<
Logger,
{
info: (msg: string) => Effect.Effect<void>;
error: (msg: string, err: unknown) => Effect.Effect<void>;
}
>() {}
// Use services — Effect tracks them in the type
function getAllUsers(): Effect.Effect<User[], DbError, Database | Logger> {
// ↑ required services
return Effect.gen(function* () {
const db = yield* Database;
const logger = yield* Logger;
yield* logger.info("Fetching all users");
const users = yield db.query<User>("SELECT FROM users", []);
yield* logger.info(Found ${users.length} users);
return users;
});
}
The third type parameter (Database | Logger) lists every dependency this function needs. The compiler won't let you run it without providing both. No runtime surprises, no "service not registered" errors in production at 2 AM.
3. Structured Concurrency
Effect manages concurrent operations with automatic cleanup. No more leaked promises or forgotten AbortControllers.
import { Effect, Fiber } from "effect";
// Run tasks concurrently with automatic error propagation
const result = yield* Effect.all(
[fetchUserProfile(id), fetchUserPosts(id), fetchUserSettings(id)],
{ concurrency: 3 }
);
// If any task fails, the others are automatically interrupted.
// No leaked promises. No dangling network requests.
// Race — first to complete wins, others get canceled
const fastest = yield* Effect.race(
fetchFromPrimary(id),
fetchFromReplica(id),
);
// Timeout with cleanup
const withTimeout = pipe(
longRunningTask(),
Effect.timeout("5 seconds"),
);
The Core Type: Effect
Everything in Effect-TS centers on this type:
Effect.Effect<A, E, R>
// ↑ ↑ ↑
// │ │ └─ R: Services/dependencies required
// │ └──── E: Error types that can occur
// └─────── A: Success value type
An Effect is a description of a computation. It doesn't execute immediately — it's lazy. You build up a pipeline of effects, then run the whole thing at the edge of your application.
// This doesn't execute anything — it describes what to do
const program = pipe(
readConfig(), // Effect<Config, ConfigError, FileSystem>
Effect.flatMap((config) =>
connectToDatabase(config.dbUrl) // Effect<DbConn, DbError, never>
),
Effect.flatMap((db) =>
runMigrations(db) // Effect<void, MigrationError, Logger>
),
);
// Type: Effect<void, ConfigError | DbError | MigrationError, FileSystem | Logger>
// Execute at the boundary
Effect.runPromise(program); // Needs all dependencies provided first
Generator Syntax (the Practical Way to Write Effect)
The pipe + flatMap chains get unwieldy. Effect's generator syntax reads like async/await:
import { Effect } from "effect";
const program = Effect.gen(function* () {
// yield* is like await, but for Effects
const config = yield* readConfig();
const db = yield* connectToDatabase(config.dbUrl);
// Error handling reads naturally
const users = yield* Effect.tryPromise({
try: () => db.query("SELECT * FROM users"),
catch: (e) => new DbError("Query failed", e),
});
// Conditional logic works normally
if (users.length === 0) {
yield* Effect.fail(new EmptyResultError("No users found"));
}
return users;
});
This is how most real Effect code looks. The generator syntax eliminates most of the functional programming ceremony while keeping the type-level guarantees.
Layers: Wiring Dependencies
Layer is how you provide implementations for your service interfaces. Think of it as a typed, composable dependency injection container.
import { Layer, Effect } from "effect";
// Implement the Database service
const DatabaseLive = Layer.effect(
Database,
Effect.gen(function* () {
const pool = yield* Effect.tryPromise({
try: () => createPool({ connectionString: process.env.DATABASE_URL }),
catch: (e) => new DbError("Connection failed", e),
});
return {
query: <T>(sql: string, params: unknown[]) =>
Effect.tryPromise({
try: () => pool.query(sql, params).then((r) => r.rows as T[]),
catch: (e) => new DbError(sql, e),
}),
};
})
);
// Implement Logger
const LoggerLive = Layer.succeed(Logger, {
info: (msg: string) => Effect.sync(() => console.log([INFO] ${msg})),
error: (msg: string, err: unknown) =>
Effect.sync(() => console.error([ERROR] ${msg}, err)),
});
// For tests — swap implementations
const DatabaseTest = Layer.succeed(Database, {
query: <T>(_sql: string, _params: unknown[]) =>
Effect.succeed([{ id: 1, name: "Test User" }] as T[]),
});
const LoggerTest = Layer.succeed(Logger, {
info: (_msg: string) => Effect.void,
error: (_msg: string, _err: unknown) => Effect.void,
});
// Compose layers
const LiveLayer = Layer.merge(DatabaseLive, LoggerLive);
const TestLayer = Layer.merge(DatabaseTest, LoggerTest);
// Run with real dependencies
const main = pipe(
getAllUsers(),
Effect.provide(LiveLayer),
);
await Effect.runPromise(main);
// Run with test dependencies — same program, different wiring
const testResult = await Effect.runPromise(
pipe(getAllUsers(), Effect.provide(TestLayer))
);
The type system ensures LiveLayer provides everything getAllUsers needs. If you forget a service, the code doesn't compile. If you add a new dependency to getAllUsers, every call site that doesn't provide it becomes a type error.
Real Refactoring Example
Here's a real-world function refactored from standard TypeScript to Effect. A webhook handler that validates a payload, checks rate limits, and stores an event.
Before (standard TypeScript):async function handleWebhook(req: Request): Promise<Response> {
try {
const signature = req.headers.get("x-signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
const body = await req.text();
const isValid = verifySignature(body, signature, WEBHOOK_SECRET);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const payload = JSON.parse(body);
// What if JSON.parse fails? What if the schema doesn't match?
const isRateLimited = await checkRateLimit(payload.source);
if (isRateLimited) {
return new Response("Rate limited", { status: 429 });
}
await storeEvent(payload);
// What if the DB is down? What if storeEvent throws?
logger.info("Webhook processed", { source: payload.source });
return new Response("OK", { status: 200 });
} catch (err) {
// Catch-all that hides the actual error type
logger.error("Webhook failed", err);
return new Response("Internal error", { status: 500 });
}
}
After (Effect-TS):
class SignatureError {
readonly _tag = "SignatureError";
constructor(readonly reason: "missing" | "invalid") {}
}
class RateLimitError {
readonly _tag = "RateLimitError";
constructor(readonly source: string) {}
}
class PayloadError {
readonly _tag = "PayloadError";
constructor(readonly message: string) {}
}
class StorageError {
readonly _tag = "StorageError";
constructor(readonly cause: unknown) {}
}
function handleWebhook(
req: Request
): Effect.Effect<Response, never, RateLimiter | EventStore | Logger> {
return pipe(
// Validate signature
Effect.gen(function* () {
const signature = req.headers.get("x-signature");
if (!signature) yield* Effect.fail(new SignatureError("missing"));
const body = yield* Effect.promise(() => req.text());
const isValid = verifySignature(body, signature!, WEBHOOK_SECRET);
if (!isValid) yield* Effect.fail(new SignatureError("invalid"));
// Parse payload
const payload = yield* Effect.try({
try: () => webhookSchema.parse(JSON.parse(body)),
catch: (e) => new PayloadError(String(e)),
});
// Check rate limit
const limiter = yield* RateLimiter;
const limited = yield* limiter.check(payload.source);
if (limited) yield* Effect.fail(new RateLimitError(payload.source));
// Store event
const store = yield* EventStore;
yield* store.save(payload);
const logger = yield* Logger;
yield* logger.info(Webhook processed: ${payload.source});
return new Response("OK", { status: 200 });
}),
// Map each error type to an HTTP response
Effect.catchTags({
SignatureError: (e) =>
Effect.succeed(new Response(e.reason === "missing" ? "Missing signature" : "Invalid signature", { status: 401 })),
RateLimitError: (e) =>
Effect.succeed(new Response(Rate limited: ${e.source}, { status: 429 })),
PayloadError: (e) =>
Effect.succeed(new Response(Bad payload: ${e.message}, { status: 400 })),
StorageError: (e) =>
Effect.succeed(new Response("Internal error", { status: 500 })),
}),
);
}
The Effect version is longer, but every error is explicit, every dependency is tracked, and the catchTags at the end is exhaustive — if you add a new error type and forget to handle it, the code doesn't compile.
Effect vs fp-ts
fp-ts was the previous standard for typed FP in TypeScript. Effect has largely superseded it. Here's why:
import { Effect } from "effect";
import * as E from "fp-ts/Either";
// Convert fp-ts Either to Effect
const fromEither = <E, A>(either: E.Either<E, A>): Effect.Effect<A, E> =>
E.isRight(either)
? Effect.succeed(either.right)
: Effect.fail(either.left);
The Effect Ecosystem
Effect isn't just the core library. It's a platform:
- @effect/schema — Runtime validation with static type inference (like Zod but integrated with Effect's error tracking)
- @effect/platform — HTTP client/server, file system, terminal
- @effect/sql — Database access with connection pooling
- @effect/opentelemetry — Distributed tracing built in
- @effect/cli — Build type-safe CLI tools
import { Schema } from "@effect/schema";
// Schema with Effect integration
const User = Schema.Struct({
id: Schema.Number,
name: Schema.String.pipe(Schema.minLength(1)),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
role: Schema.Literal("admin", "user", "editor"),
});
type User = typeof User.Type; // Inferred TypeScript type
// Decode with typed errors
const parseUser = Schema.decodeUnknown(User);
// Returns Effect<User, ParseError>
const result = yield* parseUser({ id: 1, name: "Alice", email: "alice@example.com", role: "admin" });
When Effect is Worth the Complexity
Use Effect when:- Your application has complex error handling with many distinct failure modes
- You need testable code with swappable dependencies (and don't want a DI framework)
- You're building long-running services that need structured concurrency and resource management
- Your team is willing to invest in learning the paradigm (budget 2-4 weeks of adjustment)
- You want compile-time guarantees about error handling completeness
- You're building a simple CRUD API — the overhead isn't justified
- Your team is unfamiliar with FP concepts and resistant to learning
- You're prototyping or building something disposable
- Your application is primarily UI-heavy with minimal business logic
- Bundle size matters significantly (Effect adds ~50KB even tree-shaken)
Getting Started
npm install effect
import { Effect, Console, pipe } from "effect";
// Your first Effect program
const program = pipe(
Console.log("Hello from Effect!"),
Effect.flatMap(() =>
Effect.tryPromise({
try: () => fetch("https://api.example.com/data"),
catch: () => new Error("Fetch failed"),
})
),
Effect.flatMap((res) =>
Effect.tryPromise({
try: () => res.json(),
catch: () => new Error("Parse failed"),
})
),
Effect.tap((data) => Console.log(Got ${JSON.stringify(data)})),
);
// Or with generators (recommended)
const programGen = Effect.gen(function* () {
yield* Console.log("Hello from Effect!");
const res = yield* Effect.tryPromise({
try: () => fetch("https://api.example.com/data"),
catch: () => new Error("Fetch failed"),
});
const data = yield* Effect.tryPromise({
try: () => res.json(),
catch: () => new Error("Parse failed"),
});
yield* Console.log(Got ${JSON.stringify(data)});
return data;
});
// Run it
Effect.runPromise(programGen).then(console.log);
Start with Effect.gen, tagged error classes, and basic pipe chains. Add Layer and Context when you need dependency injection. Add @effect/schema when you need validation. The ecosystem is modular — take what you need.
We've been using Effect for several backend services at codeup.dev where error handling complexity justified the investment. For simpler services, plain TypeScript with Zod and manual error types works fine. Pick the right tool for the actual complexity of your code, not the complexity you imagine you'll have someday.