MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Introduction to Effect-TS: Functional TypeScript

Explore Effect-TS: typed errors, dependency injection, concurrency, and robust TypeScript apps.

Effect-TSTypeScriptFunctional ProgrammingBackend

By MinhVo

Introduction

Effect-TS (now simply called "Effect") is a powerful TypeScript library that brings structured concurrency, typed errors, dependency injection, and functional programming patterns to TypeScript applications. Inspired by ZIO from the Scala ecosystem, Effect provides a comprehensive framework for building robust, maintainable, and type-safe applications. It addresses fundamental challenges in TypeScript development: handling errors explicitly rather than with try-catch, managing dependencies without a DI container, structuring concurrent operations safely, and creating composable, testable business logic.

The core insight behind Effect is that most real-world applications deal with three types of effects: computations that can fail (typed errors), computations that need dependencies (services), and computations that are asynchronous or concurrent. Traditional TypeScript handles these with a mix of try-catch, dependency injection frameworks, and Promises—all of which are loosely typed and difficult to compose. Effect unifies these concerns into a single Effect<Success, Error, Requirements> type that makes all three dimensions explicit in the type system.

While Effect has a steep learning curve, the payoff is substantial: applications built with Effect have fewer runtime errors, better testability, clearer error handling, and more predictable concurrency behavior. This guide explores Effect's core concepts, practical patterns, and how to incrementally adopt it in real TypeScript applications.

Functional programming and TypeScript

Understanding Effect-TS: Core Concepts

The Effect Type

The fundamental type in Effect is Effect<A, E, R>:

  • A (Success): The type of the value produced on success
  • E (Error): The type of error that can occur (explicit, not unknown or any)
  • R (Requirements): The services/context needed to run the effect
import { Effect } from "effect";
 
// A simple effect that succeeds with a number
const succeed: Effect.Effect<number, never, never> = Effect.succeed(42);
 
// An effect that can fail with a string error
const mightFail: Effect.Effect<number, string, never> = Effect.tryPromise({
  try: () => fetch("https://api.example.com/data").then(r => r.json()),
  catch: () => "Network error",
});
 
// An effect that requires a Database service
const needsDb: Effect.Effect<User[], DatabaseError, Database> = 
  Effect.gen(function* () {
    const db = yield* Database;
    return yield* db.query("SELECT * FROM users");
  });

Error Handling: Typed and Exhaustive

Unlike try-catch where errors are unknown, Effect makes error types explicit in the type signature. This means the compiler forces you to handle every possible error:

import { Effect, pipe } from "effect";
 
class UserNotFound {
  readonly _tag = "UserNotFound";
  constructor(readonly userId: string) {}
}
 
class DatabaseError {
  readonly _tag = "DatabaseError";
  constructor(readonly message: string) {}
}
 
// The error type is explicit: we know exactly what can go wrong
function getUser(id: string): Effect.Effect<User, UserNotFound | DatabaseError, Database> {
  return Effect.gen(function* () {
    const db = yield* Database;
    const result = yield* db.query("SELECT * FROM users WHERE id = $1", [id]);
    
    if (result.length === 0) {
      yield* Effect.fail(new UserNotFound(id));
    }
    
    return result[0];
  });
}
 
// When consuming this effect, TypeScript forces you to handle both errors
const handled = pipe(
  getUser("user-123"),
  Effect.catchTags({
    UserNotFound: (error) => Effect.succeed(null),
    DatabaseError: (error) => Effect.logError(error.message).pipe(Effect.as(null)),
  })
);

Services and Dependency Injection

Effect provides a built-in dependency injection system through the Context and Layer modules. Services are defined as interfaces and provided through layers:

import { Context, Effect, Layer } from "effect";
 
// Define a service interface
class Database extends Context.Tag("Database")<Database, {
  readonly query: <T>(sql: string, params?: unknown[]) => Effect.Effect<T[], DatabaseError>;
  readonly close: () => Effect.Effect<void>;
}>() {}
 
// Provide an implementation as a Layer
const DatabaseLive = Layer.succeed(Database, {
  query: (sql, params) => Effect.tryPromise({
    try: async () => {
      const client = await pool.connect();
      try {
        const result = await client.query(sql, params);
        return result.rows;
      } finally {
        client.release();
      }
    },
    catch: (error) => new DatabaseError(String(error)),
  }),
  close: () => Effect.sync(() => pool.end()),
});
 
// Effects that require Database declare it in their type
const listUsers = Effect.gen(function* () {
  const db = yield* Database;
  return yield* db.query<User>("SELECT * FROM users");
});
// Type: Effect<User[], DatabaseError, Database>

Concurrency and Fibers

Effect provides structured concurrency through fibers—lightweight green threads managed by the Effect runtime. Unlike raw Promises, fibers support interruption, supervision, and resource safety:

import { Effect } from "effect";
 
// Run three effects concurrently and collect results
const program = Effect.all([
  fetchUser("user-1"),
  fetchUser("user-2"),
  fetchUser("user-3"),
], { concurrency: 3 });
 
// Race two effects - first to succeed wins
const fastest = Effect.race(
  fetchFromCache("user-1"),
  fetchFromDatabase("user-1")
);
 
// Timeout: fail if effect takes too long
const withTimeout = fetchFromSlowService("user-1").pipe(
  Effect.timeout("5 seconds")
);

TypeScript advanced patterns and generics

Architecture and Design Patterns

The Layer Composition Pattern

Layers are composable units of dependency injection. Small layers combine into larger layers, and the TypeScript compiler verifies that all dependencies are satisfied:

import { Layer } from "effect";
 
// Small, focused layers
const ConfigLive = Layer.succeed(Config, { dbUrl: "postgres://localhost/myapp" });
const LoggerLive = Layer.succeed(Logger, { log: (msg) => console.log(msg) });
 
// Composed layer: Logger + Config -> Database
const DatabaseLive = Layer.effect(Database, 
  Effect.gen(function* () {
    const config = yield* Config;
    const logger = yield* Logger;
    const pool = new Pool({ connectionString: config.dbUrl });
    // ... create database implementation
  })
);
 
// Full application layer
const AppLive = Layer.mergeAll(ConfigLive, LoggerLive).pipe(
  Layer.provide(DatabaseLive),
  Layer.provide(UserServiceLive),
  Layer.provide(OrderServiceLive),
);

The Pipeline Pattern

Effect's pipe and Effect.gen enable clean data transformation pipelines:

const processOrder = (orderId: string) =>
  pipe(
    validateOrderId(orderId),
    Effect.flatMap(fetchOrder),
    Effect.flatMap(validateOrderStatus),
    Effect.flatMap(processPayment),
    Effect.flatMap(updateInventory),
    Effect.flatMap(sendConfirmation),
    Effect.catchTags({
      InvalidOrderId: () => Effect.fail(new BadRequest("Invalid order ID")),
      OrderNotFound: () => Effect.fail(new NotFound("Order not found")),
      PaymentFailed: (e) => Effect.fail(new PaymentError(e.message)),
    })
  );

Step-by-Step Implementation

Let's build a practical application with Effect to demonstrate its capabilities.

Setting Up an Effect Project

mkdir effect-app && cd effect-app
npm init -y
npm install effect
npm install -D typescript @types/node
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "declaration": true,
    "lib": ["ES2022"]
  }
}

Defining Services

// src/services/UserRepository.ts
import { Context, Effect, Layer } from "effect";
 
export class User {
  constructor(
    readonly id: string,
    readonly name: string,
    readonly email: string,
    readonly createdAt: Date
  ) {}
}
 
export class UserNotFound {
  readonly _tag = "UserNotFound" as const;
  constructor(readonly userId: string) {}
}
 
export class UserRepositoryError {
  readonly _tag = "UserRepositoryError" as const;
  constructor(readonly message: string, readonly cause?: unknown) {}
}
 
export class UserRepository extends Context.Tag("UserRepository")<UserRepository, {
  readonly findById: (id: string) => Effect.Effect<User, UserNotFound | UserRepositoryError>;
  readonly findAll: () => Effect.Effect<User[], UserRepositoryError>;
  readonly create: (data: { name: string; email: string }) => Effect.Effect<User, UserRepositoryError>;
  readonly update: (id: string, data: Partial<User>) => Effect.Effect<User, UserNotFound | UserRepositoryError>;
  readonly delete: (id: string) => Effect.Effect<void, UserNotFound | UserRepositoryError>;
}>() {}
 
// In-memory implementation for testing
export const UserRepositoryTest = Layer.succeed(UserRepository, (() => {
  const users = new Map<string, User>();
 
  return {
    findById: (id) => Effect.gen(function* () {
      const user = users.get(id);
      if (!user) return yield* Effect.fail(new UserNotFound(id));
      return user;
    }),
    findAll: () => Effect.sync(() => Array.from(users.values())),
    create: (data) => Effect.sync(() => {
      const user = new User(
        crypto.randomUUID(),
        data.name,
        data.email,
        new Date()
      );
      users.set(user.id, user);
      return user;
    }),
    update: (id, data) => Effect.gen(function* () {
      const existing = users.get(id);
      if (!existing) return yield* Effect.fail(new UserNotFound(id));
      const updated = { ...existing, ...data, id: existing.id } as User;
      users.set(id, updated);
      return updated;
    }),
    delete: (id) => Effect.gen(function* () {
      if (!users.has(id)) return yield* Effect.fail(new UserNotFound(id));
      users.delete(id);
    }),
  };
})());

Building Business Logic

// src/services/UserService.ts
import { Context, Effect } from "effect";
import { UserRepository, User, UserNotFound, UserRepositoryError } from "./UserRepository";
 
export class ValidationError {
  readonly _tag = "ValidationError" as const;
  constructor(readonly field: string, readonly message: string) {}
}
 
export class UserService extends Context.Tag("UserService")<UserService, {
  readonly getUser: (id: string) => Effect.Effect<User, UserNotFound | UserRepositoryError>;
  readonly createUser: (data: { name: string; email: string }) => Effect.Effect<User, ValidationError | UserRepositoryError>;
  readonly listUsers: () => Effect.Effect<User[], UserRepositoryError>;
}>() {}
 
export const UserServiceLive = Effect.gen(function* () {
  const userRepo = yield* UserRepository;
 
  return UserService.of({
    getUser: (id) => userRepo.findById(id),
    
    createUser: (data) => Effect.gen(function* () {
      // Validate input
      if (data.name.length < 2) {
        return yield* Effect.fail(new ValidationError("name", "Name must be at least 2 characters"));
      }
      if (!data.email.includes("@")) {
        return yield* Effect.fail(new ValidationError("email", "Invalid email address"));
      }
      
      // Create user
      return yield* userRepo.create(data);
    }),
    
    listUsers: () => userRepo.findAll(),
  });
});

HTTP API with Effect

// src/api.ts
import { Effect, Layer, pipe } from "effect";
import { UserService, UserServiceLive, ValidationError } from "./services/UserService";
import { UserRepositoryTest } from "./services/UserRepository";
 
const makeRouter = Effect.gen(function* () {
  const userService = yield* UserService;
 
  return async (request: Request): Promise<Response> => {
    const url = new URL(request.url);
 
    if (url.pathname === "/users" && request.method === "GET") {
      const result = await Effect.runPromise(
        userService.listUsers().pipe(
          Effect.catchTag("UserRepositoryError", (e) =>
            Effect.succeed([]) // Fall back to empty array
          )
        )
      );
      return Response.json({ data: result });
    }
 
    if (url.pathname === "/users" && request.method === "POST") {
      const body = await request.json();
      const result = await Effect.runPromise(
        userService.createUser(body).pipe(
          Effect.catchTags({
            ValidationError: (e) => Effect.succeed({ error: e }),
            UserRepositoryError: (e) => Effect.succeed({ error: e }),
          })
        )
      );
 
      if ("error" in result && typeof result.error === "object" && "_tag" in result.error) {
        return Response.json({ error: result.error }, { status: 400 });
      }
 
      return Response.json({ data: result }, { status: 201 });
    }
 
    return Response.json({ error: "Not found" }, { status: 404 });
  };
});
 
// Compose layers
const AppLayer = Layer.provide(UserServiceLive, UserRepositoryTest);
 
// Run the application
const program = Effect.gen(function* () {
  const router = yield* makeRouter;
  const port = 8000;
  
  Deno.serve({ port }, router);
  yield* Effect.logInfo(`Server running on port ${port}`);
});
 
Effect.runFork(program.pipe(Effect.provide(AppLayer)));

Structured Concurrency

// src/parallel-processing.ts
import { Effect, pipe } from "effect";
 
interface ProcessingResult {
  userId: string;
  score: number;
  processedAt: Date;
}
 
function fetchUserData(userId: string): Effect.Effect<Record<string, unknown>, Error> {
  return Effect.tryPromise({
    try: () => fetch(`https://api.example.com/users/${userId}`).then(r => r.json()),
    catch: (error) => new Error(`Failed to fetch user ${userId}: ${error}`),
  });
}
 
function computeScore(data: Record<string, unknown>): Effect.Effect<number, Error> {
  return Effect.sync(() => {
    // Expensive computation
    const score = Math.random() * 100;
    return Math.round(score * 100) / 100;
  });
}
 
function processUser(userId: string): Effect.Effect<ProcessingResult, Error> {
  return Effect.gen(function* () {
    const data = yield* fetchUserData(userId);
    const score = yield* computeScore(data);
    
    return {
      userId,
      score,
      processedAt: new Date(),
    };
  });
}
 
// Process multiple users concurrently with bounded parallelism
const userIds = ["user-1", "user-2", "user-3", "user-4", "user-5"];
 
const program = pipe(
  Effect.all(
    userIds.map(id => processUser(id).pipe(
      Effect.timeout("10 seconds"),
      Effect.catchAll(error => Effect.succeed({
        userId: id,
        score: 0,
        processedAt: new Date(),
        error: error.message,
      }))
    )),
    { concurrency: 3 } // Process up to 3 users in parallel
  ),
  Effect.tap(results => Effect.logInfo(`Processed ${results.length} users`))
);

Software architecture patterns

Real-World Use Cases

Use Case 1: API Gateway with Error Aggregation

Effect's typed errors make it natural to build API gateways that call multiple downstream services. Each service call declares its possible errors, and the gateway aggregates them into a unified error response. The compiler ensures every error path is handled.

Use Case 2: Data Processing Pipelines

ETL pipelines benefit from Effect's composability and resource safety. Each transformation step is an Effect that can be composed with others using pipe or Effect.gen. Resources (database connections, file handles) are automatically cleaned up using Effect.acquireRelease.

Use Case 3: Background Job Processing

Effect's structured concurrency and fiber management make it excellent for background job processing. You can run jobs with bounded parallelism, implement retries with exponential backoff, and handle cancellation gracefully—all with type-safe error handling.

Use Case 4: Configuration Management

Effect's Config module provides type-safe configuration with validation. You define your configuration schema, and Effect validates it at startup, failing fast with clear error messages if configuration is missing or invalid.

Best Practices for Production

  1. Start with Effect.gen: Use the generator syntax for readability. It's syntactic sugar for pipe and flatMap but reads like imperative code, making it easier for teams new to functional programming.

  2. Define error types with discriminated unions: Use _tag as a discriminant for error types. This enables Effect.catchTags for exhaustive error handling.

  3. Use Layers for dependency injection: Define services as Context.Tag and provide implementations through Layer. This enables easy swapping between real and test implementations.

  4. Leverage structured concurrency: Use Effect.all with { concurrency: N } for bounded parallelism. Use Effect.race for competing operations and Effect.timeout for deadlines.

  5. Make invalid states unrepresentable: Use Effect's type system to encode business rules in types. If a function requires authentication, express it as a requirement in the Effect type.

  6. Use Effect.acquireRelease for resources: This ensures resources are cleaned up even if errors occur. Database connections, file handles, and HTTP clients should all use this pattern.

  7. Log with Effect.log: Use Effect's built-in logging instead of console.log. Effect's logging is structured, supports log levels, and can be configured per-layer.

  8. Test with mock layers: Create test implementations of services using Layer.succeed. This enables unit testing business logic without real databases or external services.

Common Pitfalls and Solutions

PitfallImpactSolution
Not handling all error typesTypeScript compilation errorsUse Effect.catchTags for exhaustive error handling
Mixing Effect and raw async/awaitInconsistent error handling, broken resource safetyStay within Effect's ecosystem; use Effect.tryPromise for async operations
Over-complicating simple operationsUnnecessary complexityUse Effect.sync/Effect.succeed for simple operations; only use Effect where it adds value
Forgetting to provide dependenciesRuntime errors for missing servicesUse Layer.provide to supply all dependencies; TypeScript will catch missing requirements
Ignoring interruptionResource leaks, inconsistent stateUse Effect.acquireRelease for cleanup; handle interruption in long-running effects

Performance Optimization

// Effect performance optimization patterns
 
// 1. Bounded concurrency to prevent resource exhaustion
const processBatch = (items: Item[]) =>
  Effect.all(
    items.map(item => processItem(item)),
    { concurrency: 10 } // Limit parallel processing
  );
 
// 2. Caching with Effect
const cachedFetch = (url: string) =>
  Effect.cached(
    Effect.tryPromise({
      try: () => fetch(url).then(r => r.json()),
      catch: (error) => new FetchError(String(error)),
    }),
    "1 minute"
  );
 
// 3. Batching with Effect
const batchQuery = (ids: string[]) =>
  Effect.gen(function* () {
    // Instead of N individual queries, batch into one
    const db = yield* Database;
    return yield* db.query(
      `SELECT * FROM users WHERE id = ANY($1)`,
      [ids]
    );
  });
 
// 4. Streaming large datasets
const streamUsers = Stream.fromIterable(userIds).pipe(
  Stream.mapEffect(id => fetchUser(id), { concurrency: 5 }),
  Stream.runCollect
);

Comparison with Alternatives

FeatureEffect-TSfp-tsNeverthrowPlain TypeScript
Typed errorsBuilt-in (Effect<A, E, R>)Either monadResult typetry-catch (unknown)
Dependency injectionLayer/Context systemManualManualDI frameworks
ConcurrencyFibers, structured concurrencyPromise-basedPromise-basedPromise.all
Resource safetyacquireRelease patternManual cleanupManualtry-finally
Learning curveSteepSteepGentleNone
EcosystemGrowingMatureSmallMassive
TypeScript integrationExcellentGoodGoodNative
Bundle size~50KB~30KB~5KB0

Effect-TS is the most comprehensive solution, providing a full framework for building applications. fp-ts provides the functional building blocks but requires more manual composition. Neverthrow is a lightweight alternative for just typed errors. Choose Effect for new greenfield projects where robustness is a priority; use Neverthrow for incremental adoption in existing codebases.

Advanced Patterns and Techniques

Resource Management with acquireRelease

import { Effect } from "effect";
 
// Ensure database connections are always closed
const withDatabase = <A, E>(
  program: (db: Database) => Effect.Effect<A, E, never>
): Effect.Effect<A, E | DatabaseError, never> =>
  Effect.acquireRelease(
    // Acquire: open connection
    Effect.tryPromise({
      try: () => Pool.connect(),
      catch: (error) => new DatabaseError("Connection failed", error),
    }),
    // Release: close connection (always runs)
    (pool) => Effect.sync(() => pool.end())
  ).pipe(
    Effect.flatMap(pool => program(pool))
  );
 
// Usage: connection is guaranteed to close even if errors occur
const result = await Effect.runPromise(
  withDatabase(db => db.query("SELECT * FROM users"))
);

Retry with Exponential Backoff

import { Effect, Schedule } from "effect";
 
const flakyOperation = Effect.tryPromise({
  try: () => fetch("https://unreliable-api.com/data"),
  catch: (error) => new NetworkError(String(error)),
});
 
const withRetry = flakyOperation.pipe(
  Effect.retry(
    Schedule.exponential("100 millis").pipe(
      Schedule.compose(Schedule.recurs(5)), // Max 5 retries
      Schedule.jittered // Add randomness to prevent thundering herd
    )
  ),
  Effect.tap(() => Effect.logInfo("Operation succeeded"))
);

Testing Strategies

import { Effect, Layer } from "effect";
import { expect, it, describe } from "vitest";
 
// Test service implementation
const TestDatabase = Layer.succeed(Database, {
  query: (sql, params) => Effect.succeed([{ id: "1", name: "Test User" }]),
  close: () => Effect.void,
});
 
describe("UserService", () => {
  it("finds a user by ID", async () => {
    const program = Effect.gen(function* () {
      const service = yield* UserService;
      const user = yield* service.getUser("user-1");
      return user;
    });
 
    const result = await Effect.runPromise(
      program.pipe(
        Effect.provide(Layer.provide(UserServiceLive, TestDatabase))
      )
    );
 
    expect(result.name).toBe("Test User");
  });
 
  it("returns UserNotFound for missing users", async () => {
    const program = Effect.gen(function* () {
      const service = yield* UserService;
      return yield* service.getUser("nonexistent");
    });
 
    const exit = await Effect.runPromiseExit(
      program.pipe(
        Effect.provide(Layer.provide(UserServiceLive, TestDatabase))
      )
    );
 
    expect(exit._tag).toBe("Failure");
    // Verify the specific error type
  });
});

Future Outlook

Effect is rapidly maturing with growing adoption in the TypeScript ecosystem. The Effect ecosystem now includes @effect/schema for runtime schema validation, @effect/platform for cross-platform HTTP and file system abstractions, and @effect/cluster for distributed systems. Major companies are adopting Effect for mission-critical services where typed errors and resource safety are essential.

The convergence of Effect with TypeScript's evolving type system (template literal types, conditional types, variance annotations) creates a powerful combination. As TypeScript continues to improve its type inference, Effect's developer experience will only get better.

Conclusion

Effect-TS brings industrial-strength functional programming to TypeScript without requiring a PhD in category theory. Its typed errors eliminate uncaught exceptions, its dependency injection system provides testable architecture, and its structured concurrency prevents resource leaks and race conditions. The key takeaways are: Effect<A, E, R> makes errors and dependencies explicit in the type system, enabling compile-time verification of error handling and dependency resolution.

Layers provide composable dependency injection that's easy to test and swap between implementations. Structured concurrency through fibers provides safer parallel execution than raw Promises. Start by converting a single module's error handling to Effect, then gradually expand to services and business logic. The Effect documentation at effect.website provides interactive tutorials and comprehensive API reference.