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

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.pipeand 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/nodeInitialize TypeScript with strict settings:
npx tsc --initUpdate 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 runCreate 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.tsYou 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 startExpected 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.tsOutput:
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 endsStructured 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
Streamtype - 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:
- Typed errors mean no more
unknowncatches — the compiler tracks every failure - Services and Layers give you real dependency injection with zero runtime overhead
- Generators make Effect code look and feel like normal TypeScript
- Composable pipelines replace brittle try-catch chains with elegant flows
- 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.
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

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Building AI Agents from Scratch with TypeScript: Master the ReAct Pattern Using the Vercel AI SDK
Learn how to build AI agents from the ground up using TypeScript. This tutorial covers the ReAct pattern, tool calling, multi-step reasoning, and production-ready agent loops with the Vercel AI SDK.

Better Auth with Next.js 15: The Complete Authentication Guide for 2026
Learn how to implement full-featured authentication in Next.js 15 using Better Auth. This tutorial covers email/password, OAuth, sessions, middleware protection, and role-based access control.