← All posts
·19 min read

Claude Code with Effect TS: Typed Errors and DI

Claude CodeEffect TSTypeScriptFunctional
Claude Code with Effect TS: Typed errors, dependency injection, composable effects

Why Effect TS without CLAUDE.md generates Promise-style code that loses every benefit

Effect TS exists to solve three problems that async/await and try/catch leave unsolved: errors that vanish into the exception channel and lose their type, dependencies that are implicit global imports rather than declared requirements, and side effects that are impossible to test in isolation. The library solves all three by encoding everything into the Effect<Value, Error, Requirements> type. The first channel carries the success value. The second carries typed errors. The third declares every service the effect needs before it can run.

The problem is that Claude Code does not default to this model. Given a prompt like "write a function that fetches a user from the database and returns their profile", Claude will produce an async function that awaits a Prisma call, wraps the body in try/catch, and throws an Error on failure. That is correct TypeScript. It is not Effect. The typed error channel is gone, the database dependency is an imported singleton, and the whole function is untestable without mocking the module.

Without a CLAUDE.md to anchor it, Claude will consistently produce one of three anti-patterns when working in an Effect codebase. It will reach for async/await inside an Effect, which breaks the type inference and causes the Requirements channel to silently drop to never. It will use try/catch around Effect code, which catches nothing because Effect defers execution and does not throw during construction. Or it will use .then() chains alongside pipe, mixing two composition models that do not compose with each other.

This guide covers the CLAUDE.md configuration that anchors Claude to Effect 3's actual model: the three-channel type, generator syntax, typed error branches, service injection via Effect.Service, Effect/Schema for boundary validation, and Layer overrides for tests. If you are setting up Claude Code for the first time, the Claude Code setup guide is the right starting point. For the broader TypeScript conventions that Effect code builds on, Claude Code with TypeScript covers strict mode and the type patterns that align best with Effect.

The Effect TS CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every session. For an Effect codebase it needs to declare: the Effect version and import paths, the three-channel type signature and what each channel means, when to use generators versus pipe chains, how the error channel works and which tagged union errors are defined in the project, how services are declared and provided, how Schema validates external input, and the hard rules that block the async/await patterns Claude generates by default.

# Effect TS rules

## Stack
- Effect 3.x (not @effect/core, not fp-ts)
- TypeScript 5.x strict mode
- Import from "effect" package: Effect, pipe, Schema, Context, Layer, Exit, Cause
- Import Schema from "effect/Schema" NOT from "@effect/schema" (deprecated)

## Effect type: three channels
Every effectful function returns Effect<Value, Error, Requirements>:
- Value: the success type (what you get when it works)
- Error: the typed failure type (a tagged union of all things that can go wrong)
- Requirements: services this effect needs before it can run (never = no deps)

Examples:
- Effect<User, DatabaseError, never>      # no services required, can fail with DatabaseError
- Effect<Order, NotFoundError | PaymentError, Database | EmailService>
- Effect<void, never, never>              # always succeeds, no deps (use for logging)

NEVER annotate return types as Promise<T>. Use Effect<T, E, R>.

## Constructing effects
- Effect.succeed(value)                   # wraps a plain value
- Effect.fail(new MyError())              # typed failure
- Effect.sync(() => impureSyncOp())       # wraps a sync side effect
- Effect.promise(() => somePromise)       # wraps a Promise (Error channel = never)
- Effect.tryPromise({ try: () => fetch(...), catch: (e) => new FetchError(e) })
- Effect.try({ try: () => JSON.parse(s), catch: (e) => new ParseError(e) })

NEVER use async/await inside Effect.gen or pipe chains.
NEVER use try/catch around Effect code. Use error channel operators instead.

## Generator syntax (prefer for multi-step logic)
Use Effect.gen(function*() { ... }) for sequential steps. yield* unwraps effects.

const processOrder = Effect.gen(function* () {
  const db = yield* Database;              // inject service
  const user = yield* db.getUser(userId); // unwrap Effect<User, DbError, never>
  const order = yield* db.createOrder(user.id, items);
  yield* EmailService.send(user.email, order.id);
  return order;
});

Rules for generators:
- ALWAYS use function* (not arrow function*)
- ALWAYS yield* (not yield). The star matters: yield alone breaks types
- Extract services with: const svc = yield* ServiceName
- Use yield* for every Effect, even Effect<void, never, never>

## pipe chains (use for simple transformations)
Use pipe() + operators for single-effect transformations:

const getUser = (id: string) =>
  pipe(
    db.findById(id),
    Effect.map((row) => toUser(row)),
    Effect.catchTag('NotFoundError', () => Effect.fail(new UserNotFoundError(id))),
    Effect.tap((user) => Effect.log(`Loaded user ${user.id}`)),
  );

pipe vs generator decision:
- Generator: 3+ sequential steps, branching logic, service injection
- pipe: 1-2 transformations on a single effect

## Error channel
Errors are tagged classes, never plain strings or generic Error objects.

class DatabaseError extends Data.TaggedError('DatabaseError')<{
  readonly message: string;
  readonly cause?: unknown;
}> {}

class NotFoundError extends Data.TaggedError('NotFoundError')<{
  readonly resource: string;
  readonly id: string;
}> {}

Handling errors:
- Effect.catchTag('DatabaseError', (e) => Effect.fail(new RetryableError(e)))
- Effect.catchAll((e) => Effect.succeed(fallbackValue))
- Effect.either: converts Effect<A, E, R> to Effect<Either<E, A>, never, R>
- Effect.option: converts Effect<A, E, R> to Effect<Option<A>, never, R>

NEVER throw inside Effect code. NEVER use try/catch. Use error channel operators.

## Services and dependency injection
Declare services as classes extending Effect.Service:

class Database extends Effect.Service<Database>()('Database', {
  effect: Effect.gen(function* () {
    const client = yield* Effect.tryPromise({
      try: () => createClient(process.env.DATABASE_URL!),
      catch: (e) => new DatabaseError({ message: 'Connection failed', cause: e }),
    });
    return {
      getUser: (id: string) =>
        Effect.tryPromise({
          try: () => client.user.findUnique({ where: { id } }),
          catch: (e) => new DatabaseError({ message: 'Query failed', cause: e }),
        }),
    };
  }),
}) {}

Providing services:
- Effect.provide(Database.Default)        # provides the real implementation
- Effect.provide(Layer.succeed(Database, testImpl))  # provides a test double
- pipe(myEffect, Effect.provide(AppLayer)) # composes layers at the boundary

NEVER import database clients or API clients as module-level singletons.
ALWAYS declare them as services so they are injectable and testable.

## Schema for boundary validation
Use Effect/Schema at every external boundary (API input, env vars, DB rows):

import { Schema } from "effect";

const UserSchema = Schema.Struct({
  id: Schema.String,
  email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+$/)),
  age: Schema.Number.pipe(Schema.between(0, 150)),
  role: Schema.Literal('admin', 'user', 'guest'),
});

type User = Schema.Schema.Type<typeof UserSchema>;

const decodeUser = Schema.decodeUnknown(UserSchema);

// In an effect:
const parseBody = (raw: unknown) =>
  pipe(
    decodeUser(raw),
    Effect.mapError((e) => new ValidationError({ message: Schema.format(e) })),
  );

NEVER use zod for validation in Effect code, use Effect/Schema for type unification.
NEVER cast unknown as a type, always decode through Schema.

## Running effects (at the application boundary only)
- Effect.runPromise(effect)  , convert to Promise (use in Express handlers, Next.js routes)
- Effect.runSync(effect)     , synchronous run (scripts only, throws on async)
- Effect.runFork(effect)     , fire-and-forget, returns Fiber
- Effect.runPromiseExit(effect), returns Exit<A, E>, never throws

Run ONLY at the outermost boundary (main function, route handler, test runner).
NEVER call Effect.runPromise inside another effect.

## Import rules
- import { Effect, pipe, Layer, Context, Exit, Cause } from "effect"
- import { Schema } from "effect"
- import { Data } from "effect"
- NEVER import from "@effect/schema", that package is deprecated in Effect 3
- NEVER import from "effect/Effect", use the top-level "effect" package

## Hard rules
- NEVER async/await inside generators or pipe chains
- NEVER try/catch around Effect code
- NEVER throw inside an effect, use Effect.fail
- NEVER return Promise<T> from service methods, return Effect<T, E, never>
- NEVER import services as module singletons, declare as Effect.Service
- NEVER use yield (without star) in generators, always yield*
- ALWAYS tag errors with Data.TaggedError, no plain Error objects
- ALWAYS validate external input with Schema.decodeUnknown at the boundary

Four rules here prevent the most expensive mistakes Claude generates without them.

The no-async/await rule is the highest-impact entry. When Claude encounters a step that needs a Promise inside an Effect.gen block, its default is const result = await somePromise. That compiles but corrupts the type: TypeScript silently infers the generator returns Promise<Effect<...>> instead of Effect<...>, the Requirements channel drops, and the entire composition breaks at runtime with a confusing "Effect was not run" message. The correct form is yield* Effect.promise(() => somePromise). The rule forces that form.

The no-try/catch rule matters because Effect is lazy. Constructing an Effect.fail(new Error()) does not throw. Effect defers all execution until a runPromise call at the boundary. A try/catch wrapped around an effect construction catches nothing and gives a false impression of safety while the error channel carries no type information. All error handling belongs in the error channel: Effect.catchTag, Effect.catchAll, Effect.either.

The tagged error rule is what makes the error channel useful. Effect<A, Error, R> tells TypeScript nothing about what can go wrong. Effect<A, DatabaseError | NotFoundError | ValidationError, R> tells the caller exactly which errors to handle and whether they have handled all of them. The compiler enforces exhaustiveness. Claude generates new Error('something went wrong') without the tagged union pattern because that is the default TypeScript idiom. The CLAUDE.md rule forces the Data.TaggedError pattern so the error channel carries real information.

The no-module-singleton rule is what makes services testable. If Database is imported as a module-level Prisma client, every test that calls database code either hits a real database or requires module-level mocking. If Database is declared as an Effect.Service, tests provide a Layer.succeed(Database, { getUser: () => Effect.succeed(testUser) }) and the code under test never needs to know which implementation it received.

Effect type and pipe syntax

The pipe function is Effect's primary composition primitive. It threads a value through a sequence of functions, left to right. In an Effect codebase, the value being threaded is always an Effect<A, E, R> and the functions are Effect operators that transform one or more of its three channels.

Add a pipe patterns section to CLAUDE.md:

## pipe patterns

### Transforming the success value (Effect.map)
const getUserEmail = (id: string) =>
  pipe(
    db.getUser(id),                               // Effect<User, DbError, never>
    Effect.map((user) => user.email),             // Effect<string, DbError, never>
  );

### Chaining effects sequentially (Effect.flatMap)
const createUserAndSendWelcome = (input: NewUser) =>
  pipe(
    db.createUser(input),                         // Effect<User, DbError, never>
    Effect.flatMap((user) =>
      email.sendWelcome(user.email),              // Effect<void, EmailError, never>
    ),                                            // Effect<void, DbError | EmailError, never>
  );

### Running for side effects (Effect.tap)
const getAndLogUser = (id: string) =>
  pipe(
    db.getUser(id),
    Effect.tap((user) => Effect.log(`User loaded: ${user.id}`)),
    // user still flows through, tap does not change the Value channel
  );

### Catching a specific tagged error
const getOrDefault = (id: string) =>
  pipe(
    db.getUser(id),
    Effect.catchTag('NotFoundError', () => Effect.succeed(defaultUser)),
  );

### Converting errors between layers
const serviceCallWithMapping = pipe(
  db.getUser(id),
  Effect.mapError((e) => new ServiceError({ cause: e })),
);

The Effect.flatMap operator is the Effect equivalent of .then() on a Promise, but it composes error channels automatically. When db.createUser can fail with DbError and email.sendWelcome can fail with EmailError, the result type carries both. No error is silently swallowed. This is the property that async/await loses: await flattens the error channel to the catch block, losing the type information that distinguishes a database error from an email error.

Generators vs pipe chains

The Effect.gen generator syntax and the pipe chain approach are both correct. They are two syntaxes for the same composition model. The generator reads more like sequential imperative code. The pipe chain reads more like a functional transformation chain. Choosing between them is a matter of complexity, not correctness.

Add a decision guide to CLAUDE.md:

## Generator vs pipe: when to use each

### Use generators when:
- 3+ sequential steps that each depend on the previous result
- Branching logic (if/else on yielded values)
- Extracting multiple services in the same block
- The "what is happening" story matters more than "what transforms what"

const processPayment = Effect.gen(function* () {
  const db = yield* Database;
  const payment = yield* PaymentService;

  const order = yield* db.getOrder(orderId);
  if (order.status !== 'pending') {
    return yield* Effect.fail(new InvalidStateError({ orderId }));
  }

  const charge = yield* payment.charge(order.total, order.customerId);
  yield* db.markPaid(orderId, charge.id);
  return charge;
});

### Use pipe chains when:
- 1-3 operators on a single effect
- Simple map / flatMap / catchTag on one source effect
- The transformation chain reads cleanly as data flow

const getActiveUsers = pipe(
  db.getAllUsers(),
  Effect.map((users) => users.filter((u) => u.active)),
  Effect.catchTag('DatabaseError', () => Effect.succeed([])),
);

### Mixing is fine when it clarifies intent
const handler = Effect.gen(function* () {
  const db = yield* Database;
  const users = yield* pipe(
    db.getAllUsers(),
    Effect.retry(Schedule.exponential(100)),       // retry with backoff
    Effect.timeout(Duration.seconds(5)),           // timeout
  );
  return users;
});

The key CLAUDE.md insight for generators is yield* versus yield. TypeScript's generator type inference works correctly with yield* because the star tells the compiler the yielded value is being fully unwrapped. Plain yield infers any for the unwrapped type, which breaks Effect's type tracking. Claude generates plain yield occasionally, particularly when the generator body is long. The hard rule in CLAUDE.md removes that ambiguity.

Error channel: Effect.fail, Effect.either, and catchTag

The error channel is the most important concept to get right in an Effect codebase. It is also the concept Claude most consistently undermines without explicit guidance. The default TypeScript idiom for error handling is throw + catch. Effect's idiom is fail + catchTag. They look similar but behave differently: throws are dynamically typed and escape the type system; fails are statically typed and tracked at every composition step.

Add an error handling patterns section to CLAUDE.md:

## Error handling patterns

### Declaring tagged errors (always use Data.TaggedError)
import { Data } from "effect";

class UserNotFoundError extends Data.TaggedError('UserNotFoundError')<{
  readonly userId: string;
}> {}

class RateLimitError extends Data.TaggedError('RateLimitError')<{
  readonly retryAfterMs: number;
}> {}

class ValidationError extends Data.TaggedError('ValidationError')<{
  readonly field: string;
  readonly message: string;
}> {}

### Producing failures
Effect.fail(new UserNotFoundError({ userId: id }))

### Catching a specific error by tag
pipe(
  getUser(id),
  Effect.catchTag('UserNotFoundError', (e) =>
    Effect.succeed({ id: e.userId, name: 'Guest', role: 'guest' as const }),
  ),
);

### Catching multiple error tags
pipe(
  processOrder(orderId),
  Effect.catchTags({
    UserNotFoundError: (e) => Effect.fail(new OrderFailedError({ reason: 'user' })),
    RateLimitError: (e) => Effect.sleep(Duration.millis(e.retryAfterMs)).pipe(
      Effect.andThen(() => processOrder(orderId)),
    ),
  }),
);

### Converting to Either for caller-controlled handling
const result: Effect<Either<AppError, User>, never, Database> = pipe(
  getUser(id),
  Effect.either,
);
// caller can pattern match on Left/Right without handling the error at this layer

### Converting to Option when absence is valid
const maybeUser: Effect<Option<User>, never, Database> = pipe(
  db.findUser(id),
  Effect.option,
);

### Retrying with a schedule
pipe(
  apiCall(),
  Effect.retry(
    Schedule.exponential(Duration.millis(100)).pipe(
      Schedule.intersect(Schedule.recurs(3)),
    ),
  ),
);

The Effect.catchTag operator is exhaustiveness-safe. If the error type changes and UserNotFoundError is removed from the union, the TypeScript compiler flags the catchTag('UserNotFoundError', ...) call as an error because the tag no longer exists on the error type. This is a property that catch blocks cannot provide: a catch block with (e: UserNotFoundError) silently accepts any thrown value because throw is untyped. The Claude Code with tRPC guide covers a similar pattern for API error types, where typed error responses at the procedure level give the same exhaustiveness guarantee at the network boundary.

Dependency injection with Effect.Service and Context

Effect's dependency injection model is the third channel of the Effect<A, E, R> type. The R channel is a Context.Tag intersection that lists every service the effect requires. Effects with unsatisfied requirements cannot be run: the TypeScript compiler prevents calling Effect.runPromise on an Effect<A, E, Database | EmailService> because the runtime does not know where to get Database or EmailService.

Add a service injection section to CLAUDE.md:

## Services (Effect.Service)

### Declaring a service
class EmailService extends Effect.Service<EmailService>()('EmailService', {
  effect: Effect.gen(function* () {
    const apiKey = yield* Config.string('RESEND_API_KEY');
    return {
      send: (to: string, subject: string, html: string) =>
        Effect.tryPromise({
          try: () =>
            fetch('https://api.resend.com/emails', {
              method: 'POST',
              headers: { Authorization: `Bearer ${apiKey}` },
              body: JSON.stringify({ from: 'hello@example.com', to, subject, html }),
            }).then((r) => r.json()),
          catch: (e) => new EmailSendError({ cause: e }),
        }),
    };
  }),
}) {}

### Using a service in an effect
const sendWelcome = (userId: string) =>
  Effect.gen(function* () {
    const db = yield* Database;      // yields the Database service
    const email = yield* EmailService;
    const user = yield* db.getUser(userId);
    yield* email.send(user.email, 'Welcome!', welcomeHtml(user));
  });
// return type: Effect<void, DbError | EmailSendError, Database | EmailService>

### Composing layers
const AppLayer = Layer.provide(
  Database.Default,
  Layer.mergeAll(
    EmailService.Default,
    Config.layer({ RESEND_API_KEY: 'key' }),
  ),
);

const main = pipe(
  sendWelcome('user-123'),
  Effect.provide(AppLayer),
);

await Effect.runPromise(main);

### Test layer override
const TestDatabase = Layer.succeed(Database, {
  getUser: (id) => Effect.succeed({ id, email: 'test@example.com', role: 'user' }),
});

const TestLayer = Layer.provide(TestDatabase, EmailService.Default);

The pattern Claude skips most often without a CLAUDE.md is Config.string('ENV_VAR') inside service constructors. Claude defaults to process.env.RESEND_API_KEY accessed at module level. That works in production but breaks in tests because the env var may not be set, and there is no injection point to override it. Declaring environment variables as Config dependencies makes them part of the Requirements channel, which means tests can provide a Layer.succeed(Config.Tag, testConfig) and the service constructor never reads from process.env directly.

Schema-validated boundaries with Effect/Schema

Effect/Schema is the companion library for runtime validation. Unlike Zod, it generates both the TypeScript type and the runtime decoder from a single declaration. The decoder integrates directly with the Effect type: Schema.decodeUnknown(MySchema)(input) returns Effect<A, ParseError, never>, which composes directly with the rest of the pipeline without conversion.

Add a schema patterns section to CLAUDE.md:

## Effect/Schema patterns

### Declaring schemas
import { Schema } from "effect";

const OrderItemSchema = Schema.Struct({
  productId: Schema.String,
  quantity: Schema.Number.pipe(Schema.int(), Schema.positive()),
  price: Schema.Number.pipe(Schema.positive()),
});

const CreateOrderSchema = Schema.Struct({
  customerId: Schema.String,
  items: Schema.Array(OrderItemSchema).pipe(Schema.minItems(1)),
  currency: Schema.Literal('USD', 'GBP', 'EUR'),
});

type CreateOrderInput = Schema.Schema.Type<typeof CreateOrderSchema>;

### Decoding at the API boundary
const parseCreateOrder = (raw: unknown) =>
  pipe(
    Schema.decodeUnknown(CreateOrderSchema)(raw),
    Effect.mapError((e) => new ValidationError({ message: Schema.format(e) })),
  );

// In a route handler:
const createOrderHandler = (req: Request) =>
  Effect.gen(function* () {
    const body = yield* parseCreateOrder(await req.json());
    const order = yield* OrderService.create(body);
    return Response.json(order);
  });

### Encoding (reverse direction, for API responses)
const UserResponseSchema = Schema.Struct({
  id: Schema.String,
  email: Schema.String,
  createdAt: Schema.Date,  // encodes Date to ISO string automatically
});

const encodeUser = Schema.encodeSync(UserResponseSchema);

### Transformations (input shape != output shape)
const RawDbUserSchema = Schema.Struct({
  user_id: Schema.String,
  email_address: Schema.String,
});

const UserSchema = Schema.transform(
  RawDbUserSchema,
  Schema.Struct({ id: Schema.String, email: Schema.String }),
  {
    decode: (raw) => ({ id: raw.user_id, email: raw.email_address }),
    encode: (u) => ({ user_id: u.id, email_address: u.email }),
  },
);

## Rules
- ALWAYS import Schema from "effect" not "@effect/schema"
- ALWAYS decode unknown input at the boundary, never cast it
- NEVER use Zod in Effect code, the types do not compose with Effect's error channel
- Use Schema.format(parseError) to produce human-readable validation messages

The Claude Code with Zod guide covers Zod in non-Effect TypeScript projects. If your codebase is already using Zod at a framework boundary (Next.js form validation, for example) and Effect inside the domain layer, Schema.fromZod provides an interop path without migrating every schema. The CLAUDE.md rule prevents Claude from mixing the two inside the Effect pipeline where only one type system applies.

Testing with Effect.runPromise and Layer overrides

Effect code is more testable than Promise code precisely because the Requirements channel forces dependency declaration. Every service is a Layer. Tests provide alternative Layers. The code under test never needs to change.

Add a testing section to CLAUDE.md:

## Testing Effect code

### Test setup (Vitest + Effect)
import { Effect, Layer } from "effect";
import { it, expect, describe } from "vitest";

// Minimal test runner helper
const runTest = <A>(effect: Effect.Effect<A, unknown, unknown>) =>
  Effect.runPromise(effect as Effect.Effect<A, never, never>);

### Providing test layers
const MockDatabase = Layer.succeed(Database, {
  getUser: (id) =>
    id === 'exists'
      ? Effect.succeed({ id, email: 'test@example.com', role: 'user' as const })
      : Effect.fail(new NotFoundError({ resource: 'User', id })),
  createOrder: (userId, items) =>
    Effect.succeed({ id: 'order-123', userId, items, status: 'pending' as const }),
});

const MockEmail = Layer.succeed(EmailService, {
  send: (_to, _subject, _html) => Effect.succeed(void 0),
});

const TestLayer = Layer.mergeAll(MockDatabase, MockEmail);

### Writing tests
describe('processOrder', () => {
  it('creates order for existing user', async () => {
    const result = await runTest(
      processOrder({ userId: 'exists', items: [{ productId: 'p1', quantity: 1, price: 10 }] }).pipe(
        Effect.provide(TestLayer),
      ),
    );
    expect(result.status).toBe('pending');
  });

  it('fails with typed error for missing user', async () => {
    const result = await runTest(
      processOrder({ userId: 'missing', items: [] }).pipe(
        Effect.provide(TestLayer),
        Effect.either,            // convert to Either so the test does not throw
      ),
    );
    expect(result._tag).toBe('Left');
    if (result._tag === 'Left') {
      expect(result.left._tag).toBe('NotFoundError');
    }
  });
});

### Testing error paths with Effect.either
it('returns ValidationError for invalid input', async () => {
  const result = await runTest(
    parseCreateOrder({ customerId: '', items: [] }).pipe(Effect.either),
  );
  expect(result._tag).toBe('Left');
  expect(result.left._tag).toBe('ValidationError');
});

### Testing retry behaviour (use TestClock)
import { TestClock } from "effect";

it('retries on rate limit', async () => {
  let callCount = 0;
  const MockFlaky = Layer.succeed(ApiService, {
    fetch: () => {
      callCount++;
      return callCount < 3
        ? Effect.fail(new RateLimitError({ retryAfterMs: 100 }))
        : Effect.succeed({ data: 'ok' });
    },
  });

  const result = await runTest(
    callWithRetry().pipe(
      Effect.provide(MockFlaky),
      TestClock.withTestClock,
    ),
  );
  expect(result.data).toBe('ok');
  expect(callCount).toBe(3);
});

## Rules
- ALWAYS provide a TestLayer, never import real services in tests
- Use Effect.either in tests to assert on typed errors without the test throwing
- Use TestClock for time-dependent effects (retry, timeout, schedule)
- Test the effect function, not the runPromise boundary

The Effect.either pattern in tests deserves specific attention because it changes how test failures surface. Without it, a test that expects Effect.fail(new NotFoundError(...)) calls Effect.runPromise which throws, and Vitest catches the thrown value as an unhandled rejection. The test fails with a stack trace, not an assertion. With Effect.either, the failed effect returns Right(value) or Left(error) and the test asserts on ._tag and ._left without the throw. That means a test that expects a specific error type can confirm the error tag, the error fields, and that no other error type was returned.

Common gotchas and Claude's default failure modes

Even with a complete CLAUDE.md, some Effect patterns are subtle enough that Claude generates the wrong form occasionally. These are the four most frequent errors in production Effect codebases and the specific forms to watch for.

Yielding a Promise instead of an Effect. const result = yield* fetch('/api/user') looks plausible in a generator but fails at runtime because fetch returns a Promise, not an Effect. The correct form is yield* Effect.promise(() => fetch('/api/user')) or, with error handling, yield* Effect.tryPromise({ try: () => fetch('/api/user'), catch: (e) => new FetchError(e) }). Claude generates the bare Promise form when the generator is autogenerated from an async function pattern.

The wrong import path for Schema. import { Schema } from "@effect/schema" was the correct import in Effect 2. In Effect 3, @effect/schema is a deprecated compatibility shim and Schema lives in "effect". Claude's training data includes both versions. The CLAUDE.md import rule resolves this, but always verify the import in generated code.

Missing the third type parameter. Claude sometimes writes Effect<User, DatabaseError> instead of Effect<User, DatabaseError, Database>. TypeScript infers never for the missing third parameter, which means the effect appears to require no services. The error surfaces when you try to run the effect and TypeScript complains that Database is not provided. The symptom looks like a runtime error but the actual cause is a missing type annotation. Adding explicit return types to all service methods makes this category of error a compile-time failure instead of a runtime surprise.

Layer composition order. Layer.provide(A, B) means "provide B to A", which is the opposite of how pipe reads. Claude occasionally reverses the arguments, producing a layer that provides the parent to the child rather than the dependency to the dependent. The generator form is harder to reverse by accident: const a = yield* ServiceA is always "get service A from the context". The pipe form for layer composition is where reversal happens. If a layer silently resolves to never for a service you expected, check the argument order first.

The patterns for avoiding these errors compose well with the TypeScript strict mode settings in Claude Code with TypeScript: noUncheckedIndexedAccess, exactOptionalPropertyTypes, and strict: true all catch the implicit any that yields without star and missing type parameters produce at the type level before they become runtime failures.

Building a reliable Effect codebase with Claude Code

The Effect CLAUDE.md in this guide produces code where errors are statically typed and exhaustively handled because they live in the second channel of the Effect type, not in catch blocks. Dependencies are declared in the third channel and injectable because they are Effect.Service classes, not module singletons. External input is validated at the boundary with Effect/Schema, which produces typed parse errors that compose directly with the pipeline. Tests provide alternative Layers and assert on typed errors via Effect.either rather than catching thrown exceptions.

The underlying principle is that Claude defaults to idiomatic TypeScript, which is async/await with try/catch. Effect is a different programming model that happens to compile as TypeScript. Without a CLAUDE.md that explains the model, Claude treats Effect as a thin wrapper around Promises and generates code that compiles but misses every benefit: the typed error channel is lost to exceptions, the Requirements channel is never populated because services are imported as singletons, and tests require module mocking because the service layer is invisible to the type system.

With the configuration in this guide, Claude generates code that stays in the Effect model throughout: generators with yield*, tagged errors in the second channel, services in the third channel, Schema at every boundary, and Layers that swap cleanly between production and test. The failure mode shrinks to one category: the application behaviour changed. Every type error is a real type error, not a missing type annotation on an implicit any.

For the mechanics of how CLAUDE.md is read at session start and how to structure it alongside your application code, see CLAUDE.md explained. Claudify includes an Effect TS CLAUDE.md template pre-configured with the three-channel type rules, generator conventions, tagged error patterns, service injection, Schema boundaries, and the testing layer shown in this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame