Effect-TS: Type-Safe Error Handling, Services, and Pipelines for Production TypeScript

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Stop losing errors in try-catch black holes. Effect-TS gives you type-safe errors, composable pipelines, and built-in dependency injection — all while staying 100% TypeScript. In this tutorial, you will build a production-ready user management service with typed errors, retries, and testable dependencies.

What You Will Learn

By the end of this tutorial, you will:

  • Understand what Effect-TS is and why it is gaining massive adoption in 2026
  • Model typed errors that the compiler tracks through your entire application
  • Build composable pipelines with Effect.pipe and generators
  • Implement dependency injection using the Service/Layer pattern
  • Handle concurrency with structured concurrency primitives
  • Add retries, timeouts, and scheduling with zero boilerplate
  • Write testable services by swapping layers in tests
  • Build a complete user management API from scratch

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript 5.4+ experience (generics, type inference, discriminated unions)
  • Familiarity with async/await patterns
  • Basic understanding of dependency injection concepts
  • A code editor with TypeScript support (VS Code recommended)

Why Effect-TS?

Traditional TypeScript error handling has a fundamental problem: try-catch erases type information. When you catch an error, TypeScript types it as unknown — you have no idea what went wrong at compile time.

// Traditional approach — errors are invisible to the type system
async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error("Failed to fetch user");
  return response.json();
}
 
// What errors can this throw? The type signature doesn't tell you.
// Could be: NetworkError, NotFoundError, ParseError, TimeoutError...

Effect-TS solves this by encoding errors in the type signature:

import { Effect } from "effect";
 
// Effect<User, NetworkError | NotFoundError, UserService>
// Success type ↑     Error types ↑              Dependencies ↑

Every function declares exactly what can go wrong and what dependencies it needs. The compiler enforces this at every call site.


Step 1: Project Setup

Create a new project and install Effect:

mkdir effect-user-service && cd effect-user-service
npm init -y
npm install effect @effect/platform @effect/schema
npm install -D typescript tsx @types/node

Initialize TypeScript with strict settings:

npx tsc --init

Update your tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Create the project structure:

mkdir -p src/{errors,services,layers,models}

Add a run script to package.json:

{
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "start": "tsx src/main.ts"
  }
}

Step 2: Understanding the Effect Type

The core of Effect-TS is the Effect type. Think of it as a supercharged Promise that tracks three things:

//  Effect<Success, Error, Requirements>
//
//  Success      — the value produced on success
//  Error        — the possible error types (union)
//  Requirements — the services/dependencies needed to run

Create src/basics.ts to explore the fundamentals:

import { Effect, Console } from "effect";
 
// A simple Effect that succeeds with a string
const greeting = Effect.succeed("Hello, Effect!");
 
// A simple Effect that fails with an error
const failure = Effect.fail("Something went wrong");
 
// Effects are lazy — nothing runs until you execute them
// This is just a description of a computation
 
// Transform values with map
const loudGreeting = greeting.pipe(
  Effect.map((msg) => msg.toUpperCase())
);
 
// Chain effects with flatMap
const program = Effect.flatMap(greeting, (msg) => {
  return Console.log(msg);
});
 
// Use generators for imperative-style code (recommended)
const generatorProgram = Effect.gen(function* () {
  const msg = yield* greeting;
  yield* Console.log(msg);
  yield* Console.log("Effect-TS is awesome!");
});
 
// Run the program
Effect.runPromise(generatorProgram).then(() => {
  console.log("Done!");
});

Run it:

npx tsx src/basics.ts

You should see:

Hello, Effect!
Effect-TS is awesome!
Done!

Generators vs pipe: Both styles are fully equivalent. Generators (Effect.gen) give you imperative-looking code with yield*, while pipe gives you a functional composition style. Most teams prefer generators for readability. Use whichever fits your team.


Step 3: Defining Typed Errors

The real power of Effect begins with typed errors. Instead of throwing generic Error objects, you define specific error classes that the type system tracks.

Create src/errors/user-errors.ts:

import { Data } from "effect";
 
// Data.TaggedError creates a tagged union member with a _tag field
// This enables exhaustive pattern matching
 
export class UserNotFoundError extends Data.TaggedError(
  "UserNotFoundError"
)<{
  readonly userId: string;
}> {}
 
export class UserAlreadyExistsError extends Data.TaggedError(
  "UserAlreadyExistsError"
)<{
  readonly email: string;
}> {}
 
export class ValidationError extends Data.TaggedError(
  "ValidationError"
)<{
  readonly field: string;
  readonly message: string;
}> {}
 
export class DatabaseError extends Data.TaggedError(
  "DatabaseError"
)<{
  readonly operation: string;
  readonly cause: unknown;
}> {}
 
export class NetworkError extends Data.TaggedError(
  "NetworkError"
)<{
  readonly url: string;
  readonly statusCode: number;
}> {}

Now use them in a function:

import { Effect } from "effect";
import { UserNotFoundError, ValidationError } from "./errors/user-errors.js";
 
// The return type explicitly declares: this can fail with
// UserNotFoundError OR ValidationError
function getUser(
  id: string
): Effect.Effect<User, UserNotFoundError | ValidationError> {
  return Effect.gen(function* () {
    if (!id || id.length === 0) {
      return yield* new ValidationError({
        field: "id",
        message: "User ID cannot be empty",
      });
    }
 
    const user = yield* lookupUser(id);
 
    if (!user) {
      return yield* new UserNotFoundError({ userId: id });
    }
 
    return user;
  });
}

The compiler now knows exactly what errors getUser can produce. If you call getUser and forget to handle UserNotFoundError, the type system will tell you.


Step 4: Building the User Model

Create src/models/user.ts using @effect/schema for runtime validation:

import { Schema } from "@effect/schema";
 
// Define the User schema — validates at runtime, infers types at compile time
export const UserSchema = Schema.Struct({
  id: Schema.String.pipe(Schema.nonEmptyString()),
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  name: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(100)),
  role: Schema.Literal("admin", "user", "moderator"),
  createdAt: Schema.Date,
  updatedAt: Schema.Date,
});
 
// Infer the TypeScript type from the schema
export type User = typeof UserSchema.Type;
 
// Schema for creating a new user (no id, dates auto-generated)
export const CreateUserSchema = Schema.Struct({
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  name: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(100)),
  role: Schema.optional(Schema.Literal("admin", "user", "moderator"), {
    default: () => "user" as const,
  }),
});
 
export type CreateUser = typeof CreateUserSchema.Type;
 
// Schema for updating an existing user
export const UpdateUserSchema = Schema.Struct({
  email: Schema.optional(
    Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
  ),
  name: Schema.optional(
    Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(100))
  ),
  role: Schema.optional(Schema.Literal("admin", "user", "moderator")),
});
 
export type UpdateUser = typeof UpdateUserSchema.Type;

Step 5: Creating Services with Dependency Injection

Effect-TS has a built-in dependency injection system using Services and Layers. Services define what capabilities your program needs; Layers provide the implementation.

Create src/services/user-repository.ts:

import { Effect, Context, Layer } from "effect";
import type { User, CreateUser, UpdateUser } from "../models/user.js";
import {
  UserNotFoundError,
  UserAlreadyExistsError,
  DatabaseError,
} from "../errors/user-errors.js";
 
// 1. Define the service interface using Context.Tag
export class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    readonly findById: (
      id: string
    ) => Effect.Effect<User, UserNotFoundError | DatabaseError>;
 
    readonly findByEmail: (
      email: string
    ) => Effect.Effect<User | null, DatabaseError>;
 
    readonly create: (
      data: CreateUser
    ) => Effect.Effect<User, UserAlreadyExistsError | DatabaseError>;
 
    readonly update: (
      id: string,
      data: UpdateUser
    ) => Effect.Effect<User, UserNotFoundError | DatabaseError>;
 
    readonly delete: (
      id: string
    ) => Effect.Effect<void, UserNotFoundError | DatabaseError>;
 
    readonly list: () => Effect.Effect<ReadonlyArray<User>, DatabaseError>;
  }
>() {}

Now create an in-memory implementation in src/layers/in-memory-user-repository.ts:

import { Effect, Layer, Ref } from "effect";
import { UserRepository } from "../services/user-repository.js";
import type { User, CreateUser, UpdateUser } from "../models/user.js";
import {
  UserNotFoundError,
  UserAlreadyExistsError,
  DatabaseError,
} from "../errors/user-errors.js";
 
// In-memory implementation using Effect's Ref (mutable reference)
export const InMemoryUserRepository = Layer.effect(
  UserRepository,
  Effect.gen(function* () {
    // Ref is like a thread-safe mutable variable
    const store = yield* Ref.make<Map<string, User>>(new Map());
    let counter = 0;
 
    return {
      findById: (id: string) =>
        Effect.gen(function* () {
          const users = yield* Ref.get(store);
          const user = users.get(id);
          if (!user) {
            return yield* new UserNotFoundError({ userId: id });
          }
          return user;
        }),
 
      findByEmail: (email: string) =>
        Effect.gen(function* () {
          const users = yield* Ref.get(store);
          for (const user of users.values()) {
            if (user.email === email) return user;
          }
          return null;
        }),
 
      create: (data: CreateUser) =>
        Effect.gen(function* () {
          const existing = yield* Effect.flatMap(
            Ref.get(store),
            (users) => {
              for (const user of users.values()) {
                if (user.email === data.email) {
                  return Effect.succeed(user);
                }
              }
              return Effect.succeed(null);
            }
          );
 
          if (existing) {
            return yield* new UserAlreadyExistsError({
              email: data.email,
            });
          }
 
          counter++;
          const now = new Date();
          const user: User = {
            id: `user_${counter}`,
            email: data.email,
            name: data.name,
            role: data.role ?? "user",
            createdAt: now,
            updatedAt: now,
          };
 
          yield* Ref.update(store, (map) => {
            const next = new Map(map);
            next.set(user.id, user);
            return next;
          });
 
          return user;
        }),
 
      update: (id: string, data: UpdateUser) =>
        Effect.gen(function* () {
          const users = yield* Ref.get(store);
          const existing = users.get(id);
          if (!existing) {
            return yield* new UserNotFoundError({ userId: id });
          }
 
          const updated: User = {
            ...existing,
            ...Object.fromEntries(
              Object.entries(data).filter(([, v]) => v !== undefined)
            ),
            updatedAt: new Date(),
          };
 
          yield* Ref.update(store, (map) => {
            const next = new Map(map);
            next.set(id, updated);
            return next;
          });
 
          return updated;
        }),
 
      delete: (id: string) =>
        Effect.gen(function* () {
          const users = yield* Ref.get(store);
          if (!users.has(id)) {
            return yield* new UserNotFoundError({ userId: id });
          }
          yield* Ref.update(store, (map) => {
            const next = new Map(map);
            next.delete(id);
            return next;
          });
        }),
 
      list: () =>
        Effect.gen(function* () {
          const users = yield* Ref.get(store);
          return Array.from(users.values());
        }),
    };
  })
);

Step 6: Building the User Service

Now create a higher-level service that adds business logic on top of the repository. Create src/services/user-service.ts:

import { Effect, Context, Layer } from "effect";
import { Schema } from "@effect/schema";
import { UserRepository } from "./user-repository.js";
import {
  CreateUserSchema,
  UpdateUserSchema,
  type User,
} from "../models/user.js";
import {
  UserNotFoundError,
  UserAlreadyExistsError,
  ValidationError,
  DatabaseError,
} from "../errors/user-errors.js";
 
// Define the UserService interface
export class UserService extends Context.Tag("UserService")<
  UserService,
  {
    readonly getUser: (
      id: string
    ) => Effect.Effect<
      User,
      UserNotFoundError | DatabaseError
    >;
 
    readonly createUser: (
      input: unknown
    ) => Effect.Effect<
      User,
      ValidationError | UserAlreadyExistsError | DatabaseError
    >;
 
    readonly updateUser: (
      id: string,
      input: unknown
    ) => Effect.Effect<
      User,
      ValidationError | UserNotFoundError | DatabaseError
    >;
 
    readonly deleteUser: (
      id: string
    ) => Effect.Effect<
      void,
      UserNotFoundError | DatabaseError
    >;
 
    readonly listUsers: () => Effect.Effect<
      ReadonlyArray<User>,
      DatabaseError
    >;
  }
>() {}
 
// Implementation that depends on UserRepository
export const UserServiceLive = Layer.effect(
  UserService,
  Effect.gen(function* () {
    // Pull in the UserRepository dependency
    const repo = yield* UserRepository;
 
    return {
      getUser: (id: string) => repo.findById(id),
 
      createUser: (input: unknown) =>
        Effect.gen(function* () {
          // Validate input against the schema
          const parsed = yield* Schema.decodeUnknown(
            CreateUserSchema
          )(input).pipe(
            Effect.mapError(
              (error) =>
                new ValidationError({
                  field: "input",
                  message: `Invalid input: ${error.message}`,
                })
            )
          );
 
          // Business rule: check for duplicate email
          const existing = yield* repo.findByEmail(parsed.email);
          if (existing) {
            return yield* new UserAlreadyExistsError({
              email: parsed.email,
            });
          }
 
          return yield* repo.create(parsed);
        }),
 
      updateUser: (id: string, input: unknown) =>
        Effect.gen(function* () {
          const parsed = yield* Schema.decodeUnknown(
            UpdateUserSchema
          )(input).pipe(
            Effect.mapError(
              (error) =>
                new ValidationError({
                  field: "input",
                  message: `Invalid input: ${error.message}`,
                })
            )
          );
 
          // Ensure user exists before updating
          yield* repo.findById(id);
 
          return yield* repo.update(id, parsed);
        }),
 
      deleteUser: (id: string) => repo.delete(id),
 
      listUsers: () => repo.list(),
    };
  })
);

Step 7: Error Handling Patterns

Effect gives you several powerful ways to handle errors. Create src/error-handling.ts:

import { Effect, Match } from "effect";
import { UserService } from "./services/user-service.js";
import {
  UserNotFoundError,
  ValidationError,
  DatabaseError,
} from "./errors/user-errors.js";
 
// Pattern 1: Catch specific errors
const getUserSafe = (id: string) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    return yield* service.getUser(id).pipe(
      // Catch only UserNotFoundError, let others propagate
      Effect.catchTag("UserNotFoundError", (error) =>
        Effect.succeed(null)
      )
    );
  });
 
// Pattern 2: Exhaustive error matching
const createUserWithHandling = (input: unknown) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    return yield* service.createUser(input).pipe(
      Effect.catchAll((error) => {
        // TypeScript knows this is:
        // ValidationError | UserAlreadyExistsError | DatabaseError
        return Match.value(error).pipe(
          Match.tag("ValidationError", (e) =>
            Effect.succeed({
              status: 400 as const,
              message: `Validation failed: ${e.message}`,
            })
          ),
          Match.tag("UserAlreadyExistsError", (e) =>
            Effect.succeed({
              status: 409 as const,
              message: `User with email ${e.email} already exists`,
            })
          ),
          Match.tag("DatabaseError", (e) =>
            Effect.succeed({
              status: 500 as const,
              message: `Database error during ${e.operation}`,
            })
          ),
          Match.exhaustive
        );
      })
    );
  });
 
// Pattern 3: Retry with backoff
const getUserWithRetry = (id: string) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    return yield* service.getUser(id).pipe(
      // Only retry on DatabaseError (transient), not on UserNotFoundError
      Effect.retry({
        times: 3,
        schedule: "exponential",
        while: (error) => error._tag === "DatabaseError",
      })
    );
  });
 
// Pattern 4: Provide a fallback
const getUserOrDefault = (id: string) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    return yield* service.getUser(id).pipe(
      Effect.orElseSucceed(() => ({
        id: "unknown",
        email: "unknown@example.com",
        name: "Unknown User",
        role: "user" as const,
        createdAt: new Date(),
        updatedAt: new Date(),
      }))
    );
  });
 
// Pattern 5: Map errors to a different type
const getUserForApi = (id: string) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    return yield* service.getUser(id).pipe(
      Effect.mapError((error) => ({
        code: error._tag === "UserNotFoundError" ? 404 : 500,
        message:
          error._tag === "UserNotFoundError"
            ? `User ${error.userId} not found`
            : `Database error: ${error.operation}`,
      }))
    );
  });

Key insight: Unlike try-catch, Effect errors compose. When you call a function that can fail with A | B, and then call another that can fail with C, the resulting error type is automatically A | B | C. No errors get silently swallowed.


Step 8: Composable Pipelines

Effect pipelines let you build complex data processing workflows from small, testable pieces. Create src/pipelines.ts:

import { Effect, pipe, Array as Arr } from "effect";
import { UserService } from "./services/user-service.js";
import type { User } from "./models/user.js";
 
// Build a pipeline that processes users
const processUsers = Effect.gen(function* () {
  const service = yield* UserService;
 
  // Fetch all users
  const users = yield* service.listUsers();
 
  // Pipeline: filter, transform, sort
  const processed = pipe(
    users,
    // Keep only active admins
    Arr.filter((user): user is User => user.role === "admin"),
    // Transform to a summary
    Arr.map((user) => ({
      id: user.id,
      displayName: user.name.toUpperCase(),
      email: user.email,
      daysSinceCreation: Math.floor(
        (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)
      ),
    })),
    // Sort by creation date (newest first)
    Arr.sort((a, b) => b.daysSinceCreation - a.daysSinceCreation)
  );
 
  return processed;
});
 
// Run multiple effects in parallel
const fetchMultipleUsers = (ids: string[]) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    // Effect.all runs effects in parallel by default
    const users = yield* Effect.all(
      ids.map((id) => service.getUser(id)),
      { concurrency: 5 } // limit to 5 concurrent requests
    );
 
    return users;
  });
 
// Sequential pipeline with early exit
const createBulkUsers = (inputs: unknown[]) =>
  Effect.gen(function* () {
    const service = yield* UserService;
    const created: User[] = [];
 
    for (const input of inputs) {
      // Each createUser call is typed — if validation fails,
      // the entire pipeline fails with a ValidationError
      const user = yield* service.createUser(input);
      created.push(user);
    }
 
    return created;
  });
 
// Parallel pipeline with error collection
const createBulkUsersSafe = (inputs: unknown[]) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    // allSettled collects both successes and failures
    const results = yield* Effect.allSettled(
      inputs.map((input) => service.createUser(input))
    );
 
    const successes = results.filter(
      (r): r is { _tag: "Right"; right: User } => r._tag === "Right"
    );
    const failures = results.filter(
      (r): r is { _tag: "Left"; left: unknown } => r._tag === "Left"
    );
 
    return {
      created: successes.map((s) => s.right),
      errors: failures.map((f) => f.left),
      total: inputs.length,
    };
  });

Step 9: Adding Logging and Observability

Effect has built-in structured logging. Create src/services/logged-user-service.ts:

import { Effect, Logger, LogLevel } from "effect";
import { UserService } from "./user-service.js";
 
// Wrap service methods with logging
const getUser = (id: string) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    yield* Effect.log(`Fetching user: ${id}`);
 
    const user = yield* service.getUser(id).pipe(
      Effect.tap((user) =>
        Effect.log(`Found user: ${user.name} (${user.email})`)
      ),
      Effect.tapError((error) =>
        Effect.logError(`Failed to fetch user ${id}: ${error._tag}`)
      )
    );
 
    return user;
  });
 
// Add structured annotations
const createUser = (input: unknown) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    return yield* service.createUser(input).pipe(
      Effect.annotateLogs("operation", "createUser"),
      Effect.annotateLogs("timestamp", new Date().toISOString()),
      Effect.withLogSpan("user-creation")
    );
  });
 
// Configure log level per section
const debugPipeline = Effect.gen(function* () {
  const service = yield* UserService;
 
  // This section logs at DEBUG level
  const users = yield* service.listUsers().pipe(
    Effect.tap((users) =>
      Effect.logDebug(`Found ${users.length} users`)
    )
  );
 
  return users;
}).pipe(Logger.withMinimumLogLevel(LogLevel.Debug));

Step 10: Wiring It All Together

Create the main entry point in src/main.ts:

import { Effect, Console, Layer } from "effect";
import { UserService, UserServiceLive } from "./services/user-service.js";
import { InMemoryUserRepository } from "./layers/in-memory-user-repository.js";
 
// The main program — describes WHAT to do, not HOW
const program = Effect.gen(function* () {
  const service = yield* UserService;
 
  yield* Console.log("=== Effect-TS User Management ===\n");
 
  // Create some users
  const alice = yield* service.createUser({
    name: "Alice Johnson",
    email: "alice@example.com",
    role: "admin",
  });
  yield* Console.log(`Created: ${alice.name} (${alice.id})`);
 
  const bob = yield* service.createUser({
    name: "Bob Smith",
    email: "bob@example.com",
  });
  yield* Console.log(`Created: ${bob.name} (${bob.id})`);
 
  const charlie = yield* service.createUser({
    name: "Charlie Brown",
    email: "charlie@example.com",
    role: "moderator",
  });
  yield* Console.log(`Created: ${charlie.name} (${charlie.id})`);
 
  // List all users
  const allUsers = yield* service.listUsers();
  yield* Console.log(`\nTotal users: ${allUsers.length}`);
 
  // Update a user
  const updatedBob = yield* service.updateUser(bob.id, {
    name: "Robert Smith",
    role: "admin",
  });
  yield* Console.log(`\nUpdated: ${updatedBob.name} (role: ${updatedBob.role})`);
 
  // Try to create a duplicate
  const duplicate = yield* service.createUser({
    name: "Alice Clone",
    email: "alice@example.com",
  }).pipe(
    Effect.catchTag("UserAlreadyExistsError", (e) =>
      Effect.succeed(null).pipe(
        Effect.tap(() =>
          Console.log(`\nDuplicate prevented: ${e.email} already exists`)
        )
      )
    )
  );
 
  // Try to find a non-existent user
  const ghost = yield* service.getUser("user_999").pipe(
    Effect.catchTag("UserNotFoundError", (e) =>
      Effect.succeed(null).pipe(
        Effect.tap(() =>
          Console.log(`\nUser not found: ${e.userId}`)
        )
      )
    )
  );
 
  // Delete a user
  yield* service.deleteUser(charlie.id);
  yield* Console.log(`\nDeleted: ${charlie.name}`);
 
  // Final count
  const remaining = yield* service.listUsers();
  yield* Console.log(`\nRemaining users: ${remaining.length}`);
 
  for (const user of remaining) {
    yield* Console.log(`  - ${user.name} (${user.email}) [${user.role}]`);
  }
});
 
// Wire up the layers — this is where dependency injection happens
// Layer composition: UserService needs UserRepository
const MainLayer = UserServiceLive.pipe(
  Layer.provide(InMemoryUserRepository)
);
 
// Run the program with all dependencies provided
const runnable = program.pipe(Effect.provide(MainLayer));
 
Effect.runPromise(runnable).then(() => {
  console.log("\n✓ Program completed successfully");
});

Run it:

npm start

Expected output:

=== Effect-TS User Management ===

Created: Alice Johnson (user_1)
Created: Bob Smith (user_2)
Created: Charlie Brown (user_3)

Total users: 3

Updated: Robert Smith (role: admin)

Duplicate prevented: alice@example.com already exists

User not found: user_999

Deleted: Charlie Brown

Remaining users: 2
  - Alice Johnson (alice@example.com) [admin]
  - Robert Smith (bob@example.com) [admin]

✓ Program completed successfully

Step 11: Testing with Layer Swaps

One of the biggest advantages of Effect's service pattern is testability. You can swap any layer in tests without mocking libraries.

Create src/test.ts:

import { Effect, Layer, Ref } from "effect";
import { UserService, UserServiceLive } from "./services/user-service.js";
import { UserRepository } from "./services/user-repository.js";
import {
  UserNotFoundError,
  DatabaseError,
} from "./errors/user-errors.js";
import type { User } from "./models/user.js";
 
// Create a test repository that simulates failures
const FailingRepository = Layer.succeed(UserRepository, {
  findById: (id: string) =>
    new DatabaseError({
      operation: "findById",
      cause: "Connection refused",
    }),
  findByEmail: () => Effect.succeed(null),
  create: () =>
    new DatabaseError({
      operation: "create",
      cause: "Disk full",
    }),
  update: () =>
    new DatabaseError({
      operation: "update",
      cause: "Timeout",
    }),
  delete: () =>
    new DatabaseError({
      operation: "delete",
      cause: "Permission denied",
    }),
  list: () => Effect.succeed([]),
});
 
// Test: service handles database failures gracefully
const testDatabaseFailure = Effect.gen(function* () {
  const service = yield* UserService;
 
  const result = yield* service.getUser("user_1").pipe(
    Effect.matchEffect({
      onSuccess: () => Effect.succeed("FAIL: should have errored"),
      onFailure: (error) => {
        if (error._tag === "DatabaseError") {
          return Effect.succeed(
            `PASS: Got DatabaseError for ${error.operation}`
          );
        }
        return Effect.succeed(`FAIL: Wrong error type: ${error._tag}`);
      },
    })
  );
 
  console.log(result);
});
 
// Wire the failing repository into the service
const TestLayer = UserServiceLive.pipe(
  Layer.provide(FailingRepository)
);
 
// Run the test
Effect.runPromise(
  testDatabaseFailure.pipe(Effect.provide(TestLayer))
).then(() => {
  console.log("Tests completed");
});
npx tsx src/test.ts

Output:

PASS: Got DatabaseError for findById
Tests completed

No mocking library needed. Because dependencies are defined as interfaces and provided via layers, you can create any test implementation — failing, slow, recording, or perfect — and swap it in. This is real dependency injection, not monkey-patching.


Step 12: Advanced Patterns

Timeouts and Interruption

import { Effect, Duration } from "effect";
 
// Add a timeout to any effect
const getUserWithTimeout = (id: string) =>
  Effect.gen(function* () {
    const service = yield* UserService;
    return yield* service.getUser(id);
  }).pipe(
    Effect.timeout(Duration.seconds(5)),
    // Returns Option<User> — None if timeout
    Effect.map((option) =>
      option ?? { id: "timeout", name: "Timed out", email: "" }
    )
  );

Resource Management

import { Effect, Scope } from "effect";
 
// Acquire/release pattern for resources (like database connections)
const withDatabaseConnection = Effect.acquireRelease(
  // Acquire
  Effect.sync(() => {
    console.log("Opening database connection");
    return { connectionId: Math.random().toString(36) };
  }),
  // Release (always runs, even on error)
  (connection) =>
    Effect.sync(() => {
      console.log(`Closing connection ${connection.connectionId}`);
    })
);
 
const queryWithConnection = Effect.scoped(
  Effect.gen(function* () {
    const conn = yield* withDatabaseConnection;
    console.log(`Using connection: ${conn.connectionId}`);
    // Do work with the connection...
    return "query result";
  })
);
// Connection is automatically closed after the scope ends

Structured Concurrency

import { Effect, Fiber } from "effect";
 
const structuredConcurrency = Effect.gen(function* () {
  const service = yield* UserService;
 
  // Fork background work
  const fiber1 = yield* Effect.fork(service.listUsers());
  const fiber2 = yield* Effect.fork(
    service.createUser({
      name: "Async User",
      email: "async@example.com",
    })
  );
 
  // Wait for both to complete
  const [users, newUser] = yield* Effect.all([
    Fiber.join(fiber1),
    Fiber.join(fiber2),
  ]);
 
  return { users, newUser };
});

Real-World Integration: HTTP API

Here is how you would integrate Effect-TS with an HTTP framework. This example uses @effect/platform:

import { Effect } from "effect";
import { UserService } from "./services/user-service.js";
 
// Express-like route handler (conceptual)
const handleGetUser = (req: { params: { id: string } }) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    const user = yield* service.getUser(req.params.id).pipe(
      Effect.catchTag("UserNotFoundError", (e) =>
        Effect.fail({ status: 404, body: { error: `User ${e.userId} not found` } })
      ),
      Effect.catchTag("DatabaseError", (e) =>
        Effect.fail({ status: 500, body: { error: "Internal server error" } })
      )
    );
 
    return { status: 200, body: user };
  });
 
const handleCreateUser = (req: { body: unknown }) =>
  Effect.gen(function* () {
    const service = yield* UserService;
 
    const user = yield* service.createUser(req.body).pipe(
      Effect.catchTag("ValidationError", (e) =>
        Effect.fail({ status: 400, body: { error: e.message, field: e.field } })
      ),
      Effect.catchTag("UserAlreadyExistsError", (e) =>
        Effect.fail({ status: 409, body: { error: `Email ${e.email} taken` } })
      ),
      Effect.catchTag("DatabaseError", () =>
        Effect.fail({ status: 500, body: { error: "Internal server error" } })
      )
    );
 
    return { status: 201, body: user };
  });

Troubleshooting

Common Issues

"Cannot find module 'effect'" Ensure you installed the package: npm install effect. If using a monorepo, check hoisting settings.

"Type 'X' is not assignable to type 'Effect'" You likely forgot to yield* an effect inside a generator. Every Effect call inside Effect.gen must be yielded:

// Wrong
const user = service.getUser(id);
 
// Correct
const user = yield* service.getUser(id);

"Service not found: UserRepository" You forgot to provide the layer. Make sure your Layer.provide() chain includes all required services:

// Check that all dependencies are wired
const MainLayer = UserServiceLive.pipe(
  Layer.provide(InMemoryUserRepository) // This must be included!
);

Generator type inference issues If TypeScript cannot infer generator types, add explicit type annotations:

const program: Effect.Effect<User, UserNotFoundError, UserService> =
  Effect.gen(function* () {
    // ...
  });

Next Steps

Now that you have a solid foundation with Effect-TS, here are ways to go deeper:

  • @effect/platform — HTTP server, file system, and terminal services with Effect
  • @effect/sql — Type-safe SQL queries using Effect's service pattern
  • @effect/cluster — Distributed systems primitives for clustering
  • Effect Schema — Deep dive into schema composition and transformations
  • Streams — Process infinite data with Effect's Stream type
  • Effect RPC — Type-safe RPC between client and server

Useful Resources

  • Official documentation at effect.website
  • The Effect Discord community for help and discussion
  • Effect Days conference talks on YouTube

Conclusion

Effect-TS fundamentally changes how you write TypeScript. Instead of hoping errors are handled correctly at runtime, you get compile-time guarantees that every failure mode is accounted for. The service pattern gives you true dependency injection without any framework magic, and composable pipelines let you build complex workflows from simple, testable pieces.

The key takeaways:

  1. Typed errors mean no more unknown catches — the compiler tracks every failure
  2. Services and Layers give you real dependency injection with zero runtime overhead
  3. Generators make Effect code look and feel like normal TypeScript
  4. Composable pipelines replace brittle try-catch chains with elegant flows
  5. Testing is trivial — just swap layers, no mocking libraries needed

Start small: pick one service in your codebase and convert it to Effect. Once you see the type safety in action, you will not want to go back to try-catch.


Want to read more tutorials? Check out our latest tutorial on Integrating the TTN API into Your System: Developer Technical Guide.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles