Effect-TS: Type-Safe Functional Programming for Production TypeScript
Master Effect-TS for production TypeScript apps. Learn Effect, Layer, Context, typed errors, Fibers, Schema validation, and real-world patterns that replace try/catch and Promises.
Effect-TS: Type-Safe Functional Programming for Production TypeScript
TypeScript promises type safety, but most real-world apps still have a glaring blind spot: error handling. You write async function fetchUser(): Promise<User> and everything looks fine β until the function throws a NetworkError, a NotFoundError, or a PermissionDeniedError that TypeScript never told you about. Effect-TS closes that gap.
Effect-TS is a comprehensive toolkit for building production TypeScript applications with full type-safety, including errors, dependencies, and concurrency. It started as the spiritual successor to fp-ts but has grown into something far more practical: a full runtime for managing side effects, resources, and failure in a structured way.
This guide walks through the core concepts with real-world patterns you can adopt incrementally.
What Is Effect?
At its core, an Effect<A, E, R> is a description of a computation that:
- Succeeds with a value of type
A - Fails with an error of type
E - Requires a context/environment of type
R
This is fundamentally different from Promise<A> which hides errors and has no concept of dependencies.
import { Effect } from "effect";
// A computation that succeeds with a number, fails with never, needs nothing
const pure: Effect.Effect<number, never, never> = Effect.succeed(42);
// A computation that might fail with a ParseError, needs nothing
const parse: Effect.Effect<number, ParseError, never> = Effect.try({
try: () => JSON.parse("not json"),
catch: (e) => new ParseError(String(e)),
});
The third type parameter R is what makes dependency injection built-in rather than bolted on.
Typed Errors: The Key Advantage Over Promise
With Promises, errors are invisible at the type level:
// Traditional approach β errors are invisible
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Not found"); // TypeScript doesn't know about this
return res.json();
}
// Callers have no idea what can go wrong
const user = await getUser("123"); // Could throw anything!
With Effect, errors are part of the type signature:
import { Effect, Data } from "effect";
// Define error types
class NotFoundError extends Data.TaggedError("NotFoundError")<{
id: string;
}> {}
class NetworkError extends Data.TaggedError("NetworkError")<{
message: string;
}> {}
// Error types are explicit in the signature
function getUser(
id: string
): Effect.Effect<User, NotFoundError | NetworkError, never> {
return Effect.tryPromise({
try: async () => {
const res = await fetch(`/api/users/${id}`);
if (res.status === 404) throw new NotFoundError({ id });
if (!res.ok) throw new NetworkError({ message: res.statusText });
return res.json() as User;
},
catch: (e) => new NetworkError({ message: String(e) }),
});
}
Now the caller knows exactly what can go wrong and TypeScript enforces handling both cases.
Error Handling Patterns
Effect provides rich combinators for error handling:
const program = getUser("123").pipe(
// Handle specific errors, recover with fallback
Effect.catchTag("NotFoundError", (_err) =>
Effect.succeed({ id: "default", name: "Guest" } as User)
),
// Map errors to a different type
Effect.mapError((err) => `API Error: ${err._tag}`),
// Handle all errors
Effect.catchAll((err) => {
console.error(err);
return Effect.fail(new FatalError());
})
);
Compared to try/catch:
// Traditional β error types are lost, all errors look the same
try {
const user = await getUser("123");
} catch (e) {
// e is `unknown`, you have to guess what went wrong
if (e instanceof NotFoundError) { /* ... */ }
}
// Effect β fully typed, no guessing
Effect.runPromise(program).catch((e) => {
// TypeScript knows exactly what e can be
});
Context and Dependency Injection with Layer
The R type parameter enables compile-time dependency injection without a DI framework:
import { Context, Layer, Effect } from "effect";
// Define a service interface
class Database extends Context.Tag("Database")<
Database,
{
query: (sql: string) => Effect.Effect<unknown[], DatabaseError>
}
>() {}
// Use the service in your business logic
const getUsers = Effect.gen(function* () {
const db = yield* Database;
const rows = yield* db.query("SELECT * FROM users");
return rows as User[];
});
// getUsers has type: Effect<User[], DatabaseError, Database>
// TypeScript enforces that Database must be provided!
// Create a real implementation
const LiveDatabase = Layer.succeed(Database, {
query: (sql) =>
Effect.tryPromise({
try: () => pool.query(sql),
catch: (e) => new DatabaseError({ message: String(e) }),
}),
});
// Create a test implementation
const TestDatabase = Layer.succeed(Database, {
query: (_sql) => Effect.succeed([{ id: "1", name: "Test User" }]),
});
// Provide the dependency and run
const main = getUsers.pipe(Effect.provide(LiveDatabase));
Effect.runPromise(main);
This is compile-time verified DI β if you forget to provide Database, TypeScript wonβt compile.
Effect.gen: Ergonomic Sequential Code
Writing effectful code with .pipe() chains can get verbose. Effect.gen gives you a generator-based syntax that reads like async/await:
import { Effect } from "effect";
// Compare async/await style:
async function processOrder(orderId: string) {
const order = await getOrder(orderId);
const user = await getUser(order.userId);
const payment = await chargeUser(user, order.total);
await sendConfirmation(user.email, payment.id);
return payment;
}
// Effect.gen style β looks similar, but fully typed:
const processOrder = (orderId: string) =>
Effect.gen(function* () {
const order = yield* getOrder(orderId);
const user = yield* getUser(order.userId);
const payment = yield* chargeUser(user, order.total);
yield* sendConfirmation(user.email, payment.id);
return payment;
});
// processOrder now has type:
// (orderId: string) => Effect<Payment, OrderError | UserError | PaymentError | EmailError, never>
// ALL errors are tracked automatically!
Concurrency with Fibers
Effect has first-class support for structured concurrency via Fibers β lightweight, virtual threads:
import { Effect, Fiber } from "effect";
// Run effects concurrently
const parallel = Effect.gen(function* () {
// Fork two effects to run in parallel
const fiberA = yield* Effect.fork(fetchUserData("alice"));
const fiberB = yield* Effect.fork(fetchUserData("bob"));
// Wait for both to complete
const alice = yield* Fiber.join(fiberA);
const bob = yield* Fiber.join(fiberB);
return [alice, bob];
});
// Or use built-in combinators for common patterns
const concurrent = Effect.all([
fetchUserData("alice"),
fetchUserData("bob"),
fetchUserData("charlie"),
], { concurrency: "unbounded" });
// Limit concurrency
const limited = Effect.all(userIds.map(fetchUserData), {
concurrency: 5, // max 5 at a time
});
Fibers are automatically interrupted when their parent scope exits β no dangling async operations.
Schema Validation
Effect includes @effect/schema for runtime validation with automatic TypeScript type inference:
import { Schema } from "@effect/schema";
import { Effect } from "effect";
// Define schema once, get type + validator
const UserSchema = Schema.Struct({
id: Schema.String,
name: Schema.String,
age: Schema.Number.pipe(Schema.int(), Schema.positive()),
email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
role: Schema.Literal("admin", "user", "guest"),
});
type User = Schema.Schema.Type<typeof UserSchema>; // Inferred TypeScript type
// Parse with Effect error handling
const parseUser = (data: unknown) =>
Schema.decodeUnknown(UserSchema)(data).pipe(
Effect.mapError((e) => new ValidationError({ message: String(e) }))
);
// Use in API handler
const createUser = (body: unknown) =>
Effect.gen(function* () {
const user = yield* parseUser(body);
// user is typed as User here
const saved = yield* db.save(user);
return saved;
});
No separate Zod schema + TypeScript interface β one source of truth.
Resource Management with Scope
Effect handles resource acquisition/release automatically:
import { Effect, Scope } from "effect";
// Define a resource with acquisition and release
const DatabaseConnection = Effect.acquireRelease(
// Acquire
Effect.promise(() => createConnection()),
// Release β always called, even on error
(conn) => Effect.promise(() => conn.close())
);
// Use the resource safely
const program = Effect.scoped(
Effect.gen(function* () {
const conn = yield* DatabaseConnection;
const result = yield* Effect.promise(() => conn.query("SELECT 1"));
return result;
// conn.close() is called automatically here
})
);
No more try/finally for cleanup β Effectβs Scope handles it.
Real-World Pattern: HTTP Handler
Hereβs a complete example of an Express-style handler using Effect:
import { Effect, Layer, Context } from "effect";
import { Schema } from "@effect/schema";
// Request/Response types
const CreateUserRequest = Schema.Struct({
name: Schema.String.pipe(Schema.minLength(1)),
email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
});
// Services
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
create: (data: { name: string; email: string }) => Effect.Effect<User, DatabaseError>;
findByEmail: (email: string) => Effect.Effect<User | null, DatabaseError>;
}
>() {}
// Errors
class UserAlreadyExistsError extends Data.TaggedError("UserAlreadyExistsError")<{
email: string;
}> {}
// Business logic β pure, testable
const createUser = (body: unknown) =>
Effect.gen(function* () {
// Validate input
const data = yield* Schema.decodeUnknown(CreateUserRequest)(body).pipe(
Effect.mapError(() => new ValidationError())
);
// Check for duplicates
const repo = yield* UserRepository;
const existing = yield* repo.findByEmail(data.email);
if (existing) {
yield* Effect.fail(new UserAlreadyExistsError({ email: data.email }));
}
// Create user
return yield* repo.create(data);
});
// HTTP adapter β maps Effect errors to HTTP responses
const handleCreateUser = (req: Request) =>
createUser(req.body).pipe(
Effect.map((user) => ({ status: 201, body: user })),
Effect.catchTag("ValidationError", () =>
Effect.succeed({ status: 400, body: { error: "Invalid request" } })
),
Effect.catchTag("UserAlreadyExistsError", (err) =>
Effect.succeed({ status: 409, body: { error: `${err.email} already exists` } })
),
Effect.catchTag("DatabaseError", () =>
Effect.succeed({ status: 500, body: { error: "Internal error" } })
),
Effect.provide(LiveUserRepository)
);
Comparing Effect-TS to Alternatives
| Feature | Promise/async-await | fp-ts | Effect-TS |
|---|---|---|---|
| Typed errors | No | Yes | Yes |
| Dependency injection | External (tsyringe, etc.) | No | Built-in (Context/Layer) |
| Structured concurrency | Manual | No | Fibers |
| Schema validation | External (Zod, etc.) | No | @effect/schema |
| Resource management | try/finally | No | Scope |
| Learning curve | Low | Very high | Medium |
| Bundle size | Minimal | Small | ~80KB |
Effect-TS is more opinionated than fp-ts (which was very academic) but far more ergonomic. The Generator syntax (Effect.gen) makes it accessible to developers who never touched Haskell.
Getting Started
npm install effect @effect/schema
Start small β replace one problematic async function with Effect and see how the type safety propagates:
// Before
async function sendEmail(to: string, subject: string): Promise<void> {
// Could fail with anything
}
// After
class EmailDeliveryError extends Data.TaggedError("EmailDeliveryError")<{
message: string;
}> {}
function sendEmail(
to: string,
subject: string
): Effect.Effect<void, EmailDeliveryError, never> {
return Effect.tryPromise({
try: () => emailProvider.send({ to, subject }),
catch: (e) => new EmailDeliveryError({ message: String(e) }),
});
}
Summary
Effect-TS brings the correctness guarantees of functional programming to mainstream TypeScript without requiring a PhD in category theory. The key wins:
- Typed errors β every error your code can produce is visible in the type signature
- Dependency injection β services are declared in types, verified at compile time
- Structured concurrency β Fibers that clean up after themselves
- Schema validation β one source of truth for types and runtime checks
- Resource management β automatic cleanup with Scope
The learning curve is real β Effect has a lot of surface area β but you can adopt it incrementally, one function at a time. Start with error handling, add Context when you need DI, and reach for Fibers when concurrency gets complex.
For production TypeScript, Effect-TS is becoming the standard for teams that care about correctness. The question isnβt whether to use it, but when.
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.