Effect-TS Complete Guide 2026: Functional TypeScript for Production
Master Effect-TS in 2026 — type-safe error handling, concurrency with fibers, Effect Schema validation, dependency injection, vs fp-ts, and real-world production patterns.
Effect-TS (commonly called “Effect”) has emerged as the most production-ready functional programming library for TypeScript. Where fp-ts brought category theory to TypeScript developers, Effect brings something more pragmatic: a complete toolkit for building reliable, composable, and testable applications — without sacrificing developer experience.
This guide covers everything you need to use Effect in production in 2026.
What Is Effect-TS?
Effect is a TypeScript library that provides:
- Typed error handling — errors encoded in the type system, not
try/catch - Dependency injection — services declared as types, composed automatically
- Concurrency — fibers, interruption, racing, and structured concurrency
- Resource management —
Scopefor safe acquisition and release - Schema validation — runtime validation with full type inference
- Observability — built-in tracing, metrics, and logging
The core type is Effect<A, E, R>:
A— the success value typeE— the error type (typed, notunknown)R— the requirements (services/dependencies)
import { Effect } from "effect";
// Effect<string, never, never> — always succeeds with a string
const greet = Effect.succeed("Hello, Effect!");
// Effect<User, UserNotFound, Database> — may fail, needs Database service
const getUser = (id: string): Effect.Effect<User, UserNotFound, Database> =>
// ...
Getting Started
npm install effect
# or
bun add effect
No additional peer dependencies. Effect is a self-contained ecosystem.
Error Handling: The Core Idea
In Effect, errors are values — not exceptions. You don’t throw, you return failures.
import { Effect, Data } from "effect";
// Define error types
class UserNotFound extends Data.TaggedError("UserNotFound")<{
userId: string;
}> {}
class DatabaseError extends Data.TaggedError("DatabaseError")<{
message: string;
cause?: unknown;
}> {}
// Function that can fail with typed errors
const getUser = (userId: string): Effect.Effect<User, UserNotFound | DatabaseError> =>
Effect.tryPromise({
try: () => db.users.findById(userId),
catch: (error) => new DatabaseError({ message: String(error), cause: error }),
}).pipe(
Effect.flatMap((user) =>
user === null
? Effect.fail(new UserNotFound({ userId }))
: Effect.succeed(user)
)
);
Handling Errors
// Recover from specific errors
const resilient = getUser("123").pipe(
Effect.catchTag("UserNotFound", (err) =>
Effect.succeed({ id: err.userId, name: "Anonymous" })
)
);
// Recover from all errors
const withFallback = getUser("123").pipe(
Effect.orElseSucceed(() => defaultUser)
);
// Match on success and failure
const result = getUser("123").pipe(
Effect.match({
onFailure: (err) => `Error: ${err._tag}`,
onSuccess: (user) => `Hello, ${user.name}`,
})
);
// Retry on failure
const withRetry = getUser("123").pipe(
Effect.retry({
times: 3,
schedule: Schedule.exponential("100 millis"),
})
);
Error Types Are Exhaustive
getUser("123").pipe(
Effect.catchTags({
UserNotFound: (err) => Effect.succeed(null),
DatabaseError: (err) => Effect.fail(new ServiceUnavailable()),
})
// TypeScript error if you miss a case
);
Effect Schema: Runtime Validation with Full Type Inference
Effect Schema replaces zod, yup, and io-ts with a unified solution deeply integrated with Effect.
import { Schema } from "effect";
// Define schema
const UserSchema = Schema.Struct({
id: Schema.String,
name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100)),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
age: Schema.optional(Schema.Number.pipe(Schema.int(), Schema.between(0, 150))),
role: Schema.Literal("admin", "user", "guest"),
createdAt: Schema.DateFromString, // parses "2026-01-01" to Date
});
// Infer types
type User = typeof UserSchema.Type; // { id: string; name: string; email: string; ... }
type UserInput = typeof UserSchema.Encoded; // same but dates as strings
// Decode (parse untrusted input)
const decodeUser = Schema.decodeUnknown(UserSchema);
const result = await Effect.runPromise(
decodeUser({ id: "1", name: "Alice", email: "[email protected]", role: "user" })
);
Nested Schemas and Transformations
const AddressSchema = Schema.Struct({
street: Schema.String,
city: Schema.String,
country: Schema.String.pipe(Schema.length(2)), // ISO 3166-1 alpha-2
});
const OrderSchema = Schema.Struct({
id: Schema.UUID,
items: Schema.Array(
Schema.Struct({
productId: Schema.String,
quantity: Schema.Number.pipe(Schema.positive()),
price: Schema.Number.pipe(Schema.nonNegative()),
})
),
shippingAddress: AddressSchema,
total: Schema.Number.pipe(Schema.nonNegative()),
status: Schema.Literal("pending", "processing", "shipped", "delivered", "cancelled"),
});
// Parse HTTP request body
const parseOrder = Schema.decodeUnknown(OrderSchema);
app.post("/orders", async (req) => {
const order = await Effect.runPromise(
parseOrder(req.body).pipe(
Effect.mapError((parseError) => ({
status: 400,
error: "Invalid request body",
details: parseError.message,
}))
)
);
// order is fully typed as Order
});
Dependency Injection with Services
Effect’s service system is the cleanest DI in TypeScript.
Define a Service
import { Context, Effect, Layer } from "effect";
// Service interface
interface UserRepository {
readonly findById: (id: string) => Effect.Effect<User, UserNotFound>;
readonly findAll: () => Effect.Effect<User[]>;
readonly create: (input: CreateUserInput) => Effect.Effect<User, ValidationError>;
}
// Service tag (used as identifier)
const UserRepository = Context.GenericTag<UserRepository>("UserRepository");
// PostgreSQL implementation
const PostgresUserRepository = Layer.effect(
UserRepository,
Effect.gen(function* () {
const db = yield* Database; // another service
return {
findById: (id) =>
Effect.tryPromise({
try: () => db.query("SELECT * FROM users WHERE id = $1", [id]),
catch: (e) => new DatabaseError({ cause: e }),
}).pipe(
Effect.flatMap((rows) =>
rows[0] ? Effect.succeed(rows[0]) : Effect.fail(new UserNotFound({ userId: id }))
)
),
findAll: () =>
Effect.tryPromise({
try: () => db.query("SELECT * FROM users"),
catch: (e) => new DatabaseError({ cause: e }),
}),
create: (input) => createUserInDb(db, input),
};
})
);
// In-memory implementation for tests
const InMemoryUserRepository = Layer.sync(UserRepository, () => {
const users = new Map<string, User>();
return {
findById: (id) =>
users.has(id)
? Effect.succeed(users.get(id)!)
: Effect.fail(new UserNotFound({ userId: id })),
findAll: () => Effect.succeed([...users.values()]),
create: (input) => {
const user = { id: crypto.randomUUID(), ...input };
users.set(user.id, user);
return Effect.succeed(user);
},
};
});
Use Services in Business Logic
const getUserWithPosts = (userId: string) =>
Effect.gen(function* () {
const userRepo = yield* UserRepository;
const postRepo = yield* PostRepository;
const user = yield* userRepo.findById(userId);
const posts = yield* postRepo.findByUserId(userId);
return { ...user, posts };
});
Compose Layers
// Combine layers for production
const ProductionLayer = Layer.mergeAll(
PostgresUserRepository,
PostgresPostRepository,
DatabaseLayer.withPool({ max: 10 })
);
// Swap implementations for tests
const TestLayer = Layer.mergeAll(
InMemoryUserRepository,
InMemoryPostRepository
);
// Run with specific layer
Effect.runPromise(
getUserWithPosts("123").pipe(Effect.provide(ProductionLayer))
);
// In tests
Effect.runPromise(
getUserWithPosts("123").pipe(Effect.provide(TestLayer))
);
Concurrency: Fibers
Effect’s concurrency model is built on fibers — lightweight, cooperatively-scheduled threads.
Parallel Execution
import { Effect } from "effect";
// Run in parallel, collect all results
const [users, posts, comments] = yield* Effect.all(
[fetchUsers(), fetchPosts(), fetchComments()],
{ concurrency: "unbounded" } // all at once
);
// Limit concurrency
const results = yield* Effect.all(
urls.map((url) => fetchUrl(url)),
{ concurrency: 5 } // max 5 at a time
);
// Race — first to succeed wins, others are interrupted
const fastest = yield* Effect.race(fetchFromPrimary(), fetchFromReplica());
Fiber Forking
// Fork background work (fire-and-forget)
const fiber = yield* Effect.fork(sendAnalyticsEvent(event));
// Do other work...
const result = await mainWork();
// Optionally join and get the forked result
const analyticsResult = yield* Fiber.join(fiber);
Interruption
// With timeout
const result = yield* getUser("123").pipe(Effect.timeout("5 seconds"));
// Interrupt on external signal
const program = Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.sync(() => console.log("Cleaning up..."))
);
yield* longRunningWork();
});
// The finalizer runs even if interrupted
Effect.runFork(Effect.scoped(program));
Structured Concurrency
// All children are interrupted if parent is interrupted
const supervised = Effect.gen(function* () {
const fiber1 = yield* Effect.fork(worker1());
const fiber2 = yield* Effect.fork(worker2());
// Both fibers are cancelled if this effect is interrupted
const [r1, r2] = yield* Effect.all([Fiber.join(fiber1), Fiber.join(fiber2)]);
return [r1, r2];
});
Resource Management with Scope
import { Effect, Scope } from "effect";
// Acquire a resource with guaranteed cleanup
const makeDbConnection = Effect.acquireRelease(
Effect.promise(() => createConnection(config)), // acquire
(conn) => Effect.promise(() => conn.close()) // release (always runs)
);
// Use the resource
const withDb = Effect.scoped(
Effect.gen(function* () {
const conn = yield* makeDbConnection;
const result = yield* queryWith(conn, "SELECT * FROM users");
return result;
})
);
// conn.close() is called automatically, even if queryWith throws
Effect vs fp-ts
| Feature | Effect | fp-ts |
|---|---|---|
| Error typing | Built-in E type param | Manual Either<E, A> |
| Dependency injection | Built-in R type param | Manual reader monad |
| Concurrency | Fibers, structured | External (e.g. Promise) |
| Schema validation | @effect/schema | io-ts |
| Learning curve | Medium | High |
| Ecosystem | Growing, unified | Fragmented |
| Bundle size | ~200KB | ~80KB |
| Production maturity | ✅ v3 stable | ✅ mature but maintenance mode |
When to choose Effect over fp-ts:
- New projects — Effect has better DX and a unified ecosystem
- Need concurrency — fibers are far superior to manual Promise handling
- Need DI — Effect’s service system beats Reader monad every time
- Team adoption matters — Effect is more teachable
When fp-ts might still make sense:
- Very small bundle size is critical
- Existing codebase with extensive fp-ts investment
- Purely functional transformations without I/O
Real-World Patterns
HTTP API Handler
import { Effect, Schema } from "effect";
import { Hono } from "hono";
const app = new Hono();
const CreatePostBody = Schema.Struct({
title: Schema.String.pipe(Schema.minLength(1)),
content: Schema.String,
tags: Schema.Array(Schema.String),
});
app.post("/posts", async (c) => {
const program = Effect.gen(function* () {
const body = yield* Schema.decodeUnknown(CreatePostBody)(await c.req.json());
const post = yield* PostService.create(body);
return post;
}).pipe(
Effect.catchTag("ParseError", () =>
Effect.fail({ status: 400, error: "Invalid request body" })
),
Effect.catchTag("Unauthorized", () =>
Effect.fail({ status: 401, error: "Unauthorized" })
),
Effect.provide(ProductionLayer)
);
return Effect.runPromise(program).then(
(post) => c.json(post, 201),
(err) => c.json(err, err.status ?? 500)
);
});
Background Job Queue
const processQueue = Effect.gen(function* () {
const queue = yield* Queue;
while (true) {
const job = yield* queue.take();
yield* Effect.fork(
processJob(job).pipe(
Effect.retry(Schedule.exponential("1 second").pipe(Schedule.upTo("30 seconds"))),
Effect.catchAll((err) => logJobFailure(job, err))
)
);
}
}).pipe(Effect.forever);
Running Effects
// Run and get Promise (for async contexts)
const result = await Effect.runPromise(myEffect.pipe(Effect.provide(layer)));
// Run synchronously (only for pure effects, no async)
const result = Effect.runSync(Effect.succeed(42));
// Run with exit value (never throws)
const exit = await Effect.runPromiseExit(myEffect);
if (Exit.isSuccess(exit)) {
console.log(exit.value);
} else {
console.error(exit.cause);
}
Summary
Effect-TS in 2026 is the definitive answer to “how do I write production TypeScript that’s correct by construction”:
- Typed errors — no more
catch (e: unknown)guessing games - Effect Schema — replace Zod + io-ts + manual transforms with one unified API
- Services/DI — declare dependencies in types, swap implementations for testing
- Fibers — structured concurrency that makes async TypeScript manageable
- Resource safety — guaranteed cleanup via
Scope
The learning curve is real, but it pays off in correctness, testability, and maintainability. Start with Effect.gen, typed errors, and Schema validation — add fibers and layers as you need them.
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.