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)

These online tools can boost your productivity when developing with Effect-TS:


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 →

#TypeScript Effect#Effect-TS#副作用处理#函数式编程#2026