Skip to main content
← All Articles Β· Β· 7 min read

Effect-TS: Type-Safe Functional Programming for Production TypeScript

Master Effect-TS for production TypeScript apps. Learn Effect, Layer, Context, typed errors, Fibers, Schema validation, and real-world patterns that replace try/catch and Promises.

typescriptfunctional-programmingeffect-tsfp

Effect-TS: Type-Safe Functional Programming for Production TypeScript

TypeScript promises type safety, but most real-world apps still have a glaring blind spot: error handling. You write async function fetchUser(): Promise<User> and everything looks fine β€” until the function throws a NetworkError, a NotFoundError, or a PermissionDeniedError that TypeScript never told you about. Effect-TS closes that gap.

Effect-TS is a comprehensive toolkit for building production TypeScript applications with full type-safety, including errors, dependencies, and concurrency. It started as the spiritual successor to fp-ts but has grown into something far more practical: a full runtime for managing side effects, resources, and failure in a structured way.

This guide walks through the core concepts with real-world patterns you can adopt incrementally.

What Is Effect?

At its core, an Effect<A, E, R> is a description of a computation that:

  • Succeeds with a value of type A
  • Fails with an error of type E
  • Requires a context/environment of type R

This is fundamentally different from Promise<A> which hides errors and has no concept of dependencies.

import { Effect } from "effect";

// A computation that succeeds with a number, fails with never, needs nothing
const pure: Effect.Effect<number, never, never> = Effect.succeed(42);

// A computation that might fail with a ParseError, needs nothing
const parse: Effect.Effect<number, ParseError, never> = Effect.try({
  try: () => JSON.parse("not json"),
  catch: (e) => new ParseError(String(e)),
});

The third type parameter R is what makes dependency injection built-in rather than bolted on.

Typed Errors: The Key Advantage Over Promise

With Promises, errors are invisible at the type level:

// Traditional approach β€” errors are invisible
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error("Not found"); // TypeScript doesn't know about this
  return res.json();
}

// Callers have no idea what can go wrong
const user = await getUser("123"); // Could throw anything!

With Effect, errors are part of the type signature:

import { Effect, Data } from "effect";

// Define error types
class NotFoundError extends Data.TaggedError("NotFoundError")<{
  id: string;
}> {}

class NetworkError extends Data.TaggedError("NetworkError")<{
  message: string;
}> {}

// Error types are explicit in the signature
function getUser(
  id: string
): Effect.Effect<User, NotFoundError | NetworkError, never> {
  return Effect.tryPromise({
    try: async () => {
      const res = await fetch(`/api/users/${id}`);
      if (res.status === 404) throw new NotFoundError({ id });
      if (!res.ok) throw new NetworkError({ message: res.statusText });
      return res.json() as User;
    },
    catch: (e) => new NetworkError({ message: String(e) }),
  });
}

Now the caller knows exactly what can go wrong and TypeScript enforces handling both cases.

Error Handling Patterns

Effect provides rich combinators for error handling:

const program = getUser("123").pipe(
  // Handle specific errors, recover with fallback
  Effect.catchTag("NotFoundError", (_err) =>
    Effect.succeed({ id: "default", name: "Guest" } as User)
  ),

  // Map errors to a different type
  Effect.mapError((err) => `API Error: ${err._tag}`),

  // Handle all errors
  Effect.catchAll((err) => {
    console.error(err);
    return Effect.fail(new FatalError());
  })
);

Compared to try/catch:

// Traditional β€” error types are lost, all errors look the same
try {
  const user = await getUser("123");
} catch (e) {
  // e is `unknown`, you have to guess what went wrong
  if (e instanceof NotFoundError) { /* ... */ }
}

// Effect β€” fully typed, no guessing
Effect.runPromise(program).catch((e) => {
  // TypeScript knows exactly what e can be
});

Context and Dependency Injection with Layer

The R type parameter enables compile-time dependency injection without a DI framework:

import { Context, Layer, Effect } from "effect";

// Define a service interface
class Database extends Context.Tag("Database")<
  Database,
  {
    query: (sql: string) => Effect.Effect<unknown[], DatabaseError>
  }
>() {}

// Use the service in your business logic
const getUsers = Effect.gen(function* () {
  const db = yield* Database;
  const rows = yield* db.query("SELECT * FROM users");
  return rows as User[];
});

// getUsers has type: Effect<User[], DatabaseError, Database>
// TypeScript enforces that Database must be provided!

// Create a real implementation
const LiveDatabase = Layer.succeed(Database, {
  query: (sql) =>
    Effect.tryPromise({
      try: () => pool.query(sql),
      catch: (e) => new DatabaseError({ message: String(e) }),
    }),
});

// Create a test implementation
const TestDatabase = Layer.succeed(Database, {
  query: (_sql) => Effect.succeed([{ id: "1", name: "Test User" }]),
});

// Provide the dependency and run
const main = getUsers.pipe(Effect.provide(LiveDatabase));
Effect.runPromise(main);

This is compile-time verified DI β€” if you forget to provide Database, TypeScript won’t compile.

Effect.gen: Ergonomic Sequential Code

Writing effectful code with .pipe() chains can get verbose. Effect.gen gives you a generator-based syntax that reads like async/await:

import { Effect } from "effect";

// Compare async/await style:
async function processOrder(orderId: string) {
  const order = await getOrder(orderId);
  const user = await getUser(order.userId);
  const payment = await chargeUser(user, order.total);
  await sendConfirmation(user.email, payment.id);
  return payment;
}

// Effect.gen style β€” looks similar, but fully typed:
const processOrder = (orderId: string) =>
  Effect.gen(function* () {
    const order = yield* getOrder(orderId);
    const user = yield* getUser(order.userId);
    const payment = yield* chargeUser(user, order.total);
    yield* sendConfirmation(user.email, payment.id);
    return payment;
  });

// processOrder now has type:
// (orderId: string) => Effect<Payment, OrderError | UserError | PaymentError | EmailError, never>
// ALL errors are tracked automatically!

Concurrency with Fibers

Effect has first-class support for structured concurrency via Fibers β€” lightweight, virtual threads:

import { Effect, Fiber } from "effect";

// Run effects concurrently
const parallel = Effect.gen(function* () {
  // Fork two effects to run in parallel
  const fiberA = yield* Effect.fork(fetchUserData("alice"));
  const fiberB = yield* Effect.fork(fetchUserData("bob"));

  // Wait for both to complete
  const alice = yield* Fiber.join(fiberA);
  const bob = yield* Fiber.join(fiberB);

  return [alice, bob];
});

// Or use built-in combinators for common patterns
const concurrent = Effect.all([
  fetchUserData("alice"),
  fetchUserData("bob"),
  fetchUserData("charlie"),
], { concurrency: "unbounded" });

// Limit concurrency
const limited = Effect.all(userIds.map(fetchUserData), {
  concurrency: 5, // max 5 at a time
});

Fibers are automatically interrupted when their parent scope exits β€” no dangling async operations.

Schema Validation

Effect includes @effect/schema for runtime validation with automatic TypeScript type inference:

import { Schema } from "@effect/schema";
import { Effect } from "effect";

// Define schema once, get type + validator
const UserSchema = Schema.Struct({
  id: Schema.String,
  name: Schema.String,
  age: Schema.Number.pipe(Schema.int(), Schema.positive()),
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  role: Schema.Literal("admin", "user", "guest"),
});

type User = Schema.Schema.Type<typeof UserSchema>; // Inferred TypeScript type

// Parse with Effect error handling
const parseUser = (data: unknown) =>
  Schema.decodeUnknown(UserSchema)(data).pipe(
    Effect.mapError((e) => new ValidationError({ message: String(e) }))
  );

// Use in API handler
const createUser = (body: unknown) =>
  Effect.gen(function* () {
    const user = yield* parseUser(body);
    // user is typed as User here
    const saved = yield* db.save(user);
    return saved;
  });

No separate Zod schema + TypeScript interface β€” one source of truth.

Resource Management with Scope

Effect handles resource acquisition/release automatically:

import { Effect, Scope } from "effect";

// Define a resource with acquisition and release
const DatabaseConnection = Effect.acquireRelease(
  // Acquire
  Effect.promise(() => createConnection()),
  // Release β€” always called, even on error
  (conn) => Effect.promise(() => conn.close())
);

// Use the resource safely
const program = Effect.scoped(
  Effect.gen(function* () {
    const conn = yield* DatabaseConnection;
    const result = yield* Effect.promise(() => conn.query("SELECT 1"));
    return result;
    // conn.close() is called automatically here
  })
);

No more try/finally for cleanup β€” Effect’s Scope handles it.

Real-World Pattern: HTTP Handler

Here’s a complete example of an Express-style handler using Effect:

import { Effect, Layer, Context } from "effect";
import { Schema } from "@effect/schema";

// Request/Response types
const CreateUserRequest = Schema.Struct({
  name: Schema.String.pipe(Schema.minLength(1)),
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
});

// Services
class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    create: (data: { name: string; email: string }) => Effect.Effect<User, DatabaseError>;
    findByEmail: (email: string) => Effect.Effect<User | null, DatabaseError>;
  }
>() {}

// Errors
class UserAlreadyExistsError extends Data.TaggedError("UserAlreadyExistsError")<{
  email: string;
}> {}

// Business logic β€” pure, testable
const createUser = (body: unknown) =>
  Effect.gen(function* () {
    // Validate input
    const data = yield* Schema.decodeUnknown(CreateUserRequest)(body).pipe(
      Effect.mapError(() => new ValidationError())
    );

    // Check for duplicates
    const repo = yield* UserRepository;
    const existing = yield* repo.findByEmail(data.email);
    if (existing) {
      yield* Effect.fail(new UserAlreadyExistsError({ email: data.email }));
    }

    // Create user
    return yield* repo.create(data);
  });

// HTTP adapter β€” maps Effect errors to HTTP responses
const handleCreateUser = (req: Request) =>
  createUser(req.body).pipe(
    Effect.map((user) => ({ status: 201, body: user })),
    Effect.catchTag("ValidationError", () =>
      Effect.succeed({ status: 400, body: { error: "Invalid request" } })
    ),
    Effect.catchTag("UserAlreadyExistsError", (err) =>
      Effect.succeed({ status: 409, body: { error: `${err.email} already exists` } })
    ),
    Effect.catchTag("DatabaseError", () =>
      Effect.succeed({ status: 500, body: { error: "Internal error" } })
    ),
    Effect.provide(LiveUserRepository)
  );

Comparing Effect-TS to Alternatives

FeaturePromise/async-awaitfp-tsEffect-TS
Typed errorsNoYesYes
Dependency injectionExternal (tsyringe, etc.)NoBuilt-in (Context/Layer)
Structured concurrencyManualNoFibers
Schema validationExternal (Zod, etc.)No@effect/schema
Resource managementtry/finallyNoScope
Learning curveLowVery highMedium
Bundle sizeMinimalSmall~80KB

Effect-TS is more opinionated than fp-ts (which was very academic) but far more ergonomic. The Generator syntax (Effect.gen) makes it accessible to developers who never touched Haskell.

Getting Started

npm install effect @effect/schema

Start small β€” replace one problematic async function with Effect and see how the type safety propagates:

// Before
async function sendEmail(to: string, subject: string): Promise<void> {
  // Could fail with anything
}

// After
class EmailDeliveryError extends Data.TaggedError("EmailDeliveryError")<{
  message: string;
}> {}

function sendEmail(
  to: string,
  subject: string
): Effect.Effect<void, EmailDeliveryError, never> {
  return Effect.tryPromise({
    try: () => emailProvider.send({ to, subject }),
    catch: (e) => new EmailDeliveryError({ message: String(e) }),
  });
}

Summary

Effect-TS brings the correctness guarantees of functional programming to mainstream TypeScript without requiring a PhD in category theory. The key wins:

  • Typed errors β€” every error your code can produce is visible in the type signature
  • Dependency injection β€” services are declared in types, verified at compile time
  • Structured concurrency β€” Fibers that clean up after themselves
  • Schema validation β€” one source of truth for types and runtime checks
  • Resource management β€” automatic cleanup with Scope

The learning curve is real β€” Effect has a lot of surface area β€” but you can adopt it incrementally, one function at a time. Start with error handling, add Context when you need DI, and reach for Fibers when concurrency gets complex.

For production TypeScript, Effect-TS is becoming the standard for teams that care about correctness. The question isn’t whether to use it, but when.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.