TypeScript Effect System: Handling Side Effects Elegantly with Effect-TS
Why Effect-TS Is Revolutionizing TypeScript Error Handling
Async error handling has always been a pain point in TypeScript projects. try/catch can't catch async errors, Promise's .catch() is easy to miss, and async/await makes error handling implicit. Effect-TS changes the game entirely by modeling side effects as values.
| Feature | Effect-TS | Promise/async-await | fp-ts | neverthrow |
|---|---|---|---|---|
| Type-safe errors | ✅ Fully typed | ❌ unknown | ✅ Fully typed | ✅ Fully typed |
| Dependency injection | ✅ Built-in Layer | ❌ None | ❌ Manual | ❌ None |
| Concurrency primitives | ✅ Fiber | ❌ No native support | ❌ None | ❌ None |
| Retry/timeout | ✅ Built-in | ❌ Manual | ❌ Manual | ❌ Manual |
| Composability | ✅ High | ⚠️ Medium | ✅ High | ⚠️ Medium |
| Learning curve | ⚠️ Steep | ✅ Low | ❌ Steep | ✅ Low |
| Ecosystem maturity | ✅ Active | ✅ Native | ⚠️ Maintenance mode | ⚠️ Niche |
| Resource cleanup | ✅ Scope | ❌ finally | ❌ None | ❌ None |
Effect-TS has become the de facto standard for functional programming in TypeScript in 2026. Its core philosophy: describe side effects as data, not immediate execution. This makes programs testable, composable, and推理 possible.
Effect: Core Concepts and Basic Operations
Effect is the core abstraction of Effect-TS, describing a computation that might fail. Think of it as a "supercharged Promise" — but lazy, type-safe, and composable.
Creating Effects
import { Effect } from "effect";
// From a synchronous value
const syncEffect = Effect.succeed(42);
// From potentially throwing synchronous code
const riskySync = Effect.sync(() => {
const data = JSON.parse('{"name":"effect"}');
return data;
});
// From an async operation
const asyncEffect = Effect.tryPromise({
try: () => fetch("https://api.example.com/users"),
catch: (error) => new FetchError(error),
});
// Define custom error types
class FetchError {
readonly _tag = "FetchError";
constructor(readonly cause: unknown) {}
}
class ParseError {
readonly _tag = "ParseError";
constructor(readonly cause: unknown) {}
}
Chaining and Mapping
import { Effect } from "effect";
const program = Effect.gen(function* (_) {
const response = yield* _(
Effect.tryPromise({
try: () => fetch("https://api.example.com/users"),
catch: (error) => new FetchError(error),
})
);
const data = yield* _(
Effect.try({
try: () => response.json(),
catch: (error) => new ParseError(error),
})
);
return data;
});
// Mapping and chaining
const transformed = pipe(
Effect.succeed(10),
Effect.map((n) => n * 2),
Effect.flatMap((n) => Effect.succeed(n + 5))
);
Running Effects
import { Effect } from "effect";
const program = Effect.succeed("Hello, Effect-TS!");
// Run in Node.js
Effect.runPromise(program).then(console.log);
// Output: Hello, Effect-TS!
// Run synchronously (sync Effects only)
Effect.runSync(Effect.succeed(42));
// Output: 42
// Run with callback
Effect.runCallback(program, {
onExit: (exit) => {
if (exit._tag === "Success") {
console.log(exit.value);
} else {
console.error(exit.cause);
}
},
});
Layer: Dependency Injection System
Layer is Effect-TS's dependency injection mechanism, allowing you to completely separate service creation from usage. This is extremely powerful for testing and modularity.
import { Effect, Layer, Context } from "effect";
// Define service interface
class Database extends Context.Tag("Database")<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown[], DbError>;
readonly connect: () => Effect.Effect<void, DbError>;
}
>() {}
class DbError {
readonly _tag = "DbError";
constructor(readonly message: string) {}
}
// PostgreSQL Layer implementation
const PostgresLive = Layer.succeed(Database, {
query: (sql: string) =>
Effect.tryPromise({
try: () => pgPool.query(sql),
catch: (error) => new DbError(String(error)),
}),
connect: () =>
Effect.tryPromise({
try: () => pgPool.connect(),
catch: (error) => new DbError(String(error)),
}),
});
// Mock Layer (for testing)
const DatabaseMock = Layer.succeed(Database, {
query: (sql: string) => Effect.succeed([{ id: 1, name: "test" }]),
connect: () => Effect.succeed(void 0),
});
// Using the service
const getUserById = (id: number) =>
Effect.gen(function* (_) {
const db = yield* _(Database);
const rows = yield* _(db.query(`SELECT * FROM users WHERE id = ${id}`));
return rows[0];
});
// Production uses real Layer
const program = getUserById(1).pipe(
Effect.provide(PostgresLive)
);
// Tests use Mock Layer
const testProgram = getUserById(1).pipe(
Effect.provide(DatabaseMock)
);
Service: Service Definition and Composition
Service is the standard pattern for defining injectable services in Effect-TS, implemented through Context.Tag.
import { Effect, Context, Layer } from "effect";
// Define Logger service
class Logger extends Context.Tag("Logger")<
Logger,
{
readonly info: (msg: string) => Effect.Effect<void>;
readonly error: (msg: string) => Effect.Effect<void>;
}
>() {}
// Define Config service
class AppConfig extends Context.Tag("AppConfig")<
AppConfig,
{
readonly apiUrl: string;
readonly timeout: number;
}
>() {}
// Logger implementation
const LoggerLive = Layer.succeed(Logger, {
info: (msg) => Effect.sync(() => console.log(`[INFO] ${msg}`)),
error: (msg) => Effect.sync(() => console.error(`[ERROR] ${msg}`)),
});
// Config implementation
const ConfigLive = Layer.succeed(AppConfig, {
apiUrl: "https://api.example.com",
timeout: 5000,
});
// Compose multiple services
const AppLive = Layer.merge(LoggerLive, ConfigLive);
// Business logic using multiple services
const fetchWithLogging = Effect.gen(function* (_) {
const logger = yield* _(Logger);
const config = yield* _(AppConfig);
yield* _(logger.info(`Fetching from ${config.apiUrl}`));
const result = yield* _(
Effect.tryPromise({
try: () => fetch(config.apiUrl),
catch: (error) => new FetchError(error),
})
);
yield* _(logger.info("Fetch completed"));
return result;
}).pipe(Effect.provide(AppLive));
Fiber: Concurrent Programming
Fiber is Effect-TS's lightweight concurrency unit, similar to coroutines but more powerful, supporting interruption, supervision, and structured concurrency.
import { Effect, Fiber } from "effect";
// Execute multiple Effects in parallel
const parallelExample = Effect.gen(function* (_) {
const [users, posts, comments] = yield* _(
Effect.all(
[
Effect.tryPromise({
try: () => fetch("/api/users"),
catch: (e) => new FetchError(e),
}),
Effect.tryPromise({
try: () => fetch("/api/posts"),
catch: (e) => new FetchError(e),
}),
Effect.tryPromise({
try: () => fetch("/api/comments"),
catch: (e) => new FetchError(e),
}),
],
{ concurrency: "unbounded" }
)
);
return { users, posts, comments };
});
// Racing: take the fastest result
const raceExample = Effect.gen(function* (_) {
const result = yield* _(
Effect.race(
Effect.tryPromise({
try: () => fetch("/api/cdn1/data"),
catch: (e) => new FetchError(e),
}),
Effect.tryPromise({
try: () => fetch("/api/cdn2/data"),
catch: (e) => new FetchError(e),
})
)
);
return result;
});
// Manual Fiber control
const fiberExample = Effect.gen(function* (_) {
const fiber = yield* _(
Effect.fork(
Effect.tryPromise({
try: () => longRunningTask(),
catch: (e) => new TaskError(e),
})
)
);
// Do other things...
yield* _(Effect.sleep("2 seconds"));
// Wait for Fiber to complete
const result = yield* _(Fiber.join(fiber));
return result;
});
// Structured concurrency: child Fibers auto-interrupt when parent ends
const structuredConcurrency = Effect.gen(function* (_) {
yield* _(
Effect.fork(
Effect.gen(function* (_) {
yield* _(Effect.sleep("10 seconds"));
console.log("This won't execute because the parent Effect ends first");
})
)
);
yield* _(Effect.sleep("1 second"));
// Parent Effect ends, child Fiber auto-interrupts
});
From Callbacks to Promises to Effect: Complete Migration Example
The Callback Hell Era
function getUserCallback(id: number, callback: (err: Error | null, user?: User) => void) {
db.query("SELECT * FROM users WHERE id = ?", [id], (err, rows) => {
if (err) return callback(err);
if (rows.length === 0) return callback(new Error("User not found"));
callback(null, rows[0]);
});
}
function getOrdersCallback(userId: number, callback: (err: Error | null, orders?: Order[]) => void) {
db.query("SELECT * FROM orders WHERE userId = ?", [userId], (err, rows) => {
if (err) return callback(err);
callback(null, rows);
});
}
// Usage: callback hell
getUserCallback(1, (err, user) => {
if (err) { console.error(err); return; }
getOrdersCallback(user!.id, (err, orders) => {
if (err) { console.error(err); return; }
console.log({ user, orders });
});
});
The Promise/async-await Era
async function getUser(id: number): Promise<User> {
const rows = await db.query("SELECT * FROM users WHERE id = ?", [id]);
if (rows.length === 0) throw new Error("User not found");
return rows[0];
}
async function getOrders(userId: number): Promise<Order[]> {
return db.query("SELECT * FROM orders WHERE userId = ?", [userId]);
}
// Usage: error types are lost
async function main() {
try {
const user = await getUser(1);
const orders = await getOrders(user.id);
console.log({ user, orders });
} catch (error) {
// error is unknown, can't distinguish user-not-found from db-error
console.error(error);
}
}
The Effect-TS Era
class UserNotFoundError {
readonly _tag = "UserNotFoundError";
constructor(readonly userId: number) {}
}
class DbQueryError {
readonly _tag = "DbQueryError";
constructor(readonly message: string, readonly cause: unknown) {}
}
const getUser = (id: number) =>
Effect.gen(function* (_) {
const db = yield* _(Database);
const rows = yield* _(
db.query("SELECT * FROM users WHERE id = ?", [id]).pipe(
Effect.mapError((e) => new DbQueryError("Query failed", e))
)
);
if (rows.length === 0) {
return yield* _(Effect.fail(new UserNotFoundError(id)));
}
return rows[0] as User;
});
const getOrders = (userId: number) =>
Effect.gen(function* (_) {
const db = yield* _(Database);
return yield* _(db.query("SELECT * FROM orders WHERE userId = ?", [userId]));
});
// Usage: fully type-safe error handling
const program = Effect.gen(function* (_) {
const user = yield* _(getUser(1));
const orders = yield* _(getOrders(user.id));
return { user, orders };
}).pipe(
Effect.catchTag("UserNotFoundError", (e) =>
Effect.succeed({ error: `User ${e.userId} not found` })
),
Effect.catchTag("DbQueryError", (e) =>
Effect.succeed({ error: `Database error: ${e.message}` })
)
);
Error Handling Patterns
catchAll: Catch All Errors
import { Effect } from "effect";
const resilient = pipe(
riskyOperation(),
Effect.catchAll((error) =>
Effect.succeed(`Recovered from error: ${String(error)}`)
)
);
catchTag: Catch Errors by Type
import { Effect } from "effect";
const taggedHandler = pipe(
program,
Effect.catchTag("FetchError", (e) =>
Effect.succeed({ fallback: true, reason: e.cause })
),
Effect.catchTag("ParseError", (e) =>
Effect.succeed({ fallback: true, reason: e.cause })
)
);
retry: Exponential Backoff
import { Effect, Schedule } from "effect";
const retryWithBackoff = pipe(
fetchFromApi(),
Effect.retry(
Schedule.exponential("1 second").pipe(
Schedule.compose(Schedule.recurs(5)),
Schedule.tap((attempt) =>
Effect.sync(() => console.log(`Retry attempt ${attempt}...`))
)
)
)
);
// Conditional retry
const conditionalRetry = pipe(
fetchFromApi(),
Effect.retry(
Schedule.exponential("500 millis").pipe(
Schedule.whileOutput((delay) => delay < 30_000),
Schedule.whileInput((error) => error._tag === "NetworkError")
)
)
);
timeout: Timeout Control
import { Effect } from "effect";
class TimeoutError {
readonly _tag = "TimeoutError";
}
const withTimeout = pipe(
longRunningOperation(),
Effect.timeout("5 seconds"),
Effect.mapError(() => new TimeoutError())
);
// With timeout and fallback
const withTimeoutFallback = pipe(
longRunningOperation(),
Effect.timeoutFail({
duration: "3 seconds",
onTimeout: () => new TimeoutError(),
}),
Effect.catchTag("TimeoutError", () =>
Effect.succeed(getCachedResult())
)
);
Dependency Injection in Practice: Complete Example
import { Effect, Context, Layer } from "effect";
// Define error types
class HttpError {
readonly _tag = "HttpError";
constructor(readonly status: number, readonly message: string) {}
}
// Define HttpClient service
class HttpClient extends Context.Tag("HttpClient")<
HttpClient,
{
readonly get: (url: string) => Effect.Effect<Response, HttpError>;
readonly post: (url: string, body: unknown) => Effect.Effect<Response, HttpError>;
}
>() {}
// Define Cache service
class Cache extends Context.Tag("Cache")<
Cache,
{
readonly get: (key: string) => Effect.Effect<unknown | null>;
readonly set: (key: string, value: unknown, ttl: number) => Effect.Effect<void>;
}
>() {}
// Real HttpClient implementation
const HttpClientLive = Layer.succeed(HttpClient, {
get: (url) =>
Effect.tryPromise({
try: () => fetch(url),
catch: (error) => new HttpError(0, String(error)),
}).pipe(
Effect.flatMap((res) =>
res.ok
? Effect.succeed(res)
: Effect.fail(new HttpError(res.status, res.statusText))
)
),
post: (url, body) =>
Effect.tryPromise({
try: () =>
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
catch: (error) => new HttpError(0, String(error)),
}).pipe(
Effect.flatMap((res) =>
res.ok
? Effect.succeed(res)
: Effect.fail(new HttpError(res.status, res.statusText))
)
),
});
// In-memory Cache implementation
const InMemoryCacheLive = Layer.effect(
Cache,
Effect.sync(() => {
const store = new Map<string, { value: unknown; expires: number }>();
return {
get: (key) =>
Effect.sync(() => {
const entry = store.get(key);
if (!entry || entry.expires < Date.now()) return null;
return entry.value;
}),
set: (key, value, ttl) =>
Effect.sync(() => {
store.set(key, { value, expires: Date.now() + ttl });
}),
};
})
);
// Business logic: API call with cache
const fetchWithCache = (url: string) =>
Effect.gen(function* (_) {
const cache = yield* _(Cache);
const http = yield* _(HttpClient);
const cached = yield* _(cache.get(url));
if (cached) return cached;
const response = yield* _(http.get(url));
const data = yield* _(
Effect.try({
try: () => response.json(),
catch: (e) => new HttpError(0, `Parse error: ${String(e)}`),
})
);
yield* _(cache.set(url, data, 60_000));
return data;
});
// Assemble the application
const AppLayer = Layer.merge(HttpClientLive, InMemoryCacheLive);
const main = fetchWithCache("https://api.example.com/data").pipe(
Effect.provide(AppLayer),
Effect.catchTag("HttpError", (e) =>
Effect.succeed({ error: `HTTP ${e.status}: ${e.message}` })
)
);
Concurrency in Practice: Complete Fiber Example
import { Effect, Fiber, Ref, Queue } from "effect";
// Producer-consumer pattern
const producerConsumer = Effect.gen(function* (_) {
const queue = yield* _(Queue.bounded<string>(10));
const producer = Effect.gen(function* (_) {
for (let i = 0; i < 5; i++) {
yield* _(Queue.offer(queue, `message-${i}`));
yield* _(Effect.sleep("100 millis"));
}
yield* _(Queue.offer(queue, "DONE"));
});
const consumer = Effect.gen(function* (_) {
while (true) {
const msg = yield* _(Queue.take(queue));
if (msg === "DONE") break;
console.log(`Consumed: ${msg}`);
}
});
yield* _(
Effect.all([producer, consumer], { concurrency: 2 })
);
});
// Parallel requests with rate limiting
const fetchWithConcurrencyLimit = (urls: string[]) =>
Effect.gen(function* (_) {
const semaphore = yield* _(Effect.makeSemaphore(3));
const results = yield* _(
Effect.all(
urls.map((url) =>
semaphore.withPermits(1)(
Effect.tryPromise({
try: () => fetch(url),
catch: (e) => new FetchError(e),
})
)
),
{ concurrency: "unbounded" }
)
);
return results;
});
// Fiber supervision
const supervisedExample = Effect.gen(function* (_) {
const fiber = yield* _(
Effect.fork(
Effect.gen(function* (_) {
yield* _(Effect.sleep("5 seconds"));
return "done";
})
)
);
yield* _(
Fiber.interrupt(fiber).pipe(
Effect.race(Fiber.join(fiber))
)
);
});
5 Common Pitfalls and Solutions
1. Forgetting to Run Effects
// ❌ Wrong: Effects are lazy, they won't execute without running
const program = Effect.succeed(42).pipe(Effect.map(console.log));
// ✅ Correct: Must explicitly run
Effect.runPromise(program);
2. Mixing Promises Inside Effects
// ❌ Wrong: Using async/await breaks Effect's error tracking
const bad = Effect.async(() => {
const data = await fetch("/api"); // Syntax error
});
// ✅ Correct: Wrap with Effect.tryPromise
const good = Effect.tryPromise({
try: () => fetch("/api"),
catch: (e) => new FetchError(e),
});
3. Untagged Error Types
// ❌ Wrong: Raw Error can't use catchTag
const bad = Effect.fail(new Error("something went wrong"));
// ✅ Correct: Use custom errors with _tag
class AppError {
readonly _tag = "AppError";
constructor(readonly message: string) {}
}
const good = Effect.fail(new AppError("something went wrong"));
4. Missing Layer Provision
// ❌ Wrong: Using Database without providing a Layer
const program = Effect.gen(function* (_) {
const db = yield* _(Database);
return yield* _(db.query("SELECT 1"));
});
Effect.runPromise(program); // Runtime error: missing Database Layer
// ✅ Correct: Provide the Layer
Effect.runPromise(program.pipe(Effect.provide(PostgresLive)));
5. Ignoring Fiber Interruption
// ❌ Wrong: Uninterruptible Effect prevents Fiber cleanup
const bad = Effect.sync(() => {
while (true) {} // Infinite loop, can't interrupt
});
// ✅ Correct: Use Effect.yieldNow to yield control
const good = Effect.gen(function* (_) {
while (true) {
yield* _(Effect.yieldNow());
}
});
10 Error Troubleshooting Items
| # | Error Symptom | Cause | Solution |
|---|---|---|---|
| 1 | Error: Service not found: Database |
Layer not provided | Use Effect.provide() to inject Layer |
| 2 | Uncaught TypeError: Effect is not a function |
Wrong import path | Check import { Effect } from "effect" |
| 3 | Effect doesn't execute | Forgot to call Effect.runPromise etc. |
Effects are lazy, must explicitly run |
| 4 | catchTag can't match errors |
Error class missing _tag property |
Ensure custom errors have readonly _tag |
| 5 | Type inference fails with never |
Error type doesn't match catch | Check Effect's error type parameters |
| 6 | Fiber interrupted unexpectedly |
Parent Fiber ending causes child interruption | Use Effect.forkDaemon for daemon Fibers |
| 7 | Memory leak | Fibers not properly cleaned up | Use structured concurrency, avoid forkDaemon abuse |
| 8 | Schedule retry not working |
Retry condition not met | Check whileInput / whileOutput conditions |
| 9 | Layer circular dependency | Two Layers depending on each other | Refactor dependencies, use Layer.provide to split |
| 10 | timeout not working |
Wrong time unit format | Use string format "5 seconds" instead of numbers |
Effect-TS vs Promise/async-await Comparison
| Dimension | Effect-TS | Promise/async-await |
|---|---|---|
| Error types | Fully typed at compile time | unknown, requires manual assertion |
| Lazy evaluation | ✅ Deferred execution | ❌ Immediate execution |
| Composability | ✅ pipe + gen | ⚠️ Manual wrapping needed |
| Dependency injection | ✅ Layer system | ❌ No built-in solution |
| Concurrency control | ✅ Fiber + Semaphore | ❌ No native support |
| Retry strategies | ✅ Schedule | ❌ Manual implementation |
| Timeout control | ✅ Built-in | ❌ Promise.race hack |
| Resource safety | ✅ Scope + acquireRelease | ⚠️ finally not guaranteed |
| Interruption support | ✅ Fiber.interrupt | ❌ AbortController |
| Testability | ✅ Just swap Layers | ⚠️ Need module mocking |
| Debugging experience | ⚠️ Deep stack traces | ✅ Clear native stacks |
| Bundle size | ⚠️ ~80KB min | ✅ 0KB (native) |
Recommended Tools
These online tools can boost your productivity when developing with Effect-TS:
- JSON Formatter — Quickly format and validate JSON when debugging API responses
- Base64 Encode/Decode — Handle HTTP headers and Token encoding
- Hash Calculator — Generate data signatures and cache key hashes
Summary: Effect-TS brings unprecedented type safety and composability to TypeScript by modeling side effects as values. Master the four core concepts — Effect, Layer, Service, and Fiber — combined with error handling patterns like catchTag, retry, and timeout, and you'll write truly robust functional TypeScript code in 2026. The learning curve is steep, but once you master it, you'll never want to go back to raw Promises.
Try these browser-local tools — no sign-up required →