Agent Skills: Effect-TS Best Practices

Enforces Effect-TS patterns for services, errors, layers, and atoms. Use when writing code with Effect.Service, Schema.TaggedError, Layer composition, or effect-atom React components.

UncategorizedID: betalyra/effect-skills/effect-best-practices

Install this agent skill to your local

pnpm dlx add-skill https://github.com/betalyra/effect-skills/tree/HEAD/effect-best-practices

Skill Files

Browse the full folder contents for effect-best-practices.

Download Skill

Loading file tree…

effect-best-practices/SKILL.md

Skill Metadata

Name
effect-best-practices
Description
Enforces Effect-TS patterns for services, errors, layers, and atoms. Use when writing code with Effect.Service, Schema.TaggedError, Layer composition, or effect-atom React components.

Effect-TS Best Practices

This skill enforces opinionated, consistent patterns for Effect-TS codebases. These patterns optimize for type safety, testability, observability, and maintainability.

Core Principles

Effect Type Signature

Effect<Success, Error, Requirements>
//      ↑        ↑       ↑
//      |        |       └── Dependencies (provided via Layers)
//      |        └── Expected errors (typed, must be handled)
//      └── Success value

Data-First Piped Style

ALWAYS prefer data-first pipe style for composition:

// ✅ GOOD: Data-first with pipe
const result = value.pipe(
  Effect.map((n) => n * 2),
  Effect.flatMap((n) => processValue(n)),
  Effect.catchTag("NetworkError", () => Effect.succeed(fallback)),
);

// ❌ BAD: Function-first style
const result = Effect.catchTag(
  Effect.flatMap(
    Effect.map(value, (n) => n * 2),
    (n) => processValue(n),
  ),
  "NetworkError",
  () => Effect.succeed(fallback),
);

"@effect/schema" is deprecated

Don't try to install nor import from "@effect/schema", it is deprecated. Instead just import from "effect" package.

// ✅ GOOD
import { Schema } from "effect";

// ❌ BAD
import { Schema } from "@effect/schema";

Quick Reference: Critical Rules

| Category | DO | DON'T | | ----------------- | ------------------------------------------------------- | ------------------------------------------ | | Services | Effect.Service | Context.Tag for business logic | | Dependencies | dependencies: [Dep.Default] at the top of the service | Manual Layer.provide at usage sites | | Errors | Schema.TaggedError with message and cause fields | Plain classes or generic Error | | Error Specificity | UserNotFoundError, SessionExpiredError | Generic NotFoundError, BadRequestError | | Error Handling | catchTag/catchTags | catchAll or mapError | | IDs | Schema.UUID.pipe(Schema.brand("@App/EntityId")) | Plain string for entity IDs | | Functions | Effect.fn("Service.method") | Anonymous generators | | Logging | Effect.log with structured data | console.log | | Config | Config.* with validation | process.env directly | | Options | Option.match with both cases | Option.getOrThrow | | Nullability | Option<T> in domain types | null/undefined | | Atoms | Atom.make outside components | Creating atoms inside render | | Atom State | Atom.keepAlive for global state | Forgetting keepAlive for persistent state | | Atom Updates | useAtomSet in React components | Atom.update imperatively from React | | Atom Cleanup | get.addFinalizer() for side effects | Missing cleanup for event listeners | | Atom Results | Result.builder with onErrorTag | Ignoring loading/error states |

Service Definition Pattern

Always use Effect.Service for business logic services. This provides built-in Default layer and proper dependency declaration.

import { Effect } from "effect";

export class UserService extends Effect.Service<UserService>()("UserService", {
  dependencies: [UserRepo.Default, CacheService.Default],
  effect: Effect.gen(function* () {
    const repo = yield* UserRepo;
    const cache = yield* CacheService;

    const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
      const cached = yield* cache.get(id);
      if (Option.isSome(cached)) return cached.value;

      const user = yield* repo.findById(id);
      yield* cache.set(id, user);
      return user;
    });

    const create = Effect.fn("UserService.create")(function* (
      data: CreateUserInput,
    ) {
      const user = yield* repo.create(data);
      yield* Effect.log("User created", { userId: user.id });
      return user;
    });

    return { findById, create };
  }),
}) {}

// Usage - dependencies are already wired
const program = Effect.gen(function* () {
  const userService = yield* UserService;
  const user = yield* userService.findById(userId);
  return user;
});

// At app root
const MainLive = Layer.mergeAll(UserService.Default, OtherService.Default);

When Context.Tag is acceptable:

  • Infrastructure with runtime injection (Cloudflare KV, worker bindings)
  • Factory patterns where resources are provided externally

See references/service-patterns.md for detailed patterns.

Error Definition Pattern

Always use Schema.TaggedError for errors. This makes them serializable (required for RPC) and provides consistent structure.

import { Schema } from "effect";
import { HttpApiSchema } from "@effect/platform";

export class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
  "UserNotFoundError",
  {
    userId: UserId,
    message: Schema.String,
  },
  HttpApiSchema.annotations({ status: 404 }),
) {}

export class UserCreateError extends Schema.TaggedError<UserCreateError>()(
  "UserCreateError",
  {
    message: Schema.String,
    cause: Schema.optional(Schema.String),
  },
  HttpApiSchema.annotations({ status: 400 }),
) {}

Error handling - use catchTag/catchTags:

// ✅ CORRECT - preserves type information
yield *
  repo.findById(id).pipe(
    Effect.catchTag("DatabaseError", (err) =>
      Effect.fail(
        new UserNotFoundError({ userId: id, message: "Lookup failed" }),
      ),
    ),
    Effect.catchTag("ConnectionError", (err) =>
      Effect.fail(
        new ServiceUnavailableError({ message: "Database unreachable" }),
      ),
    ),
  );

// ✅ CORRECT - multiple tags at once
yield *
  effect.pipe(
    Effect.catchTags({
      DatabaseError: (err) =>
        Effect.fail(
          new UserNotFoundError({ userId: id, message: err.message }),
        ),
      ValidationError: (err) =>
        Effect.fail(
          new InvalidEmailError({ email: input.email, message: err.message }),
        ),
    }),
  );

Prefer Explicit Over Generic Errors

Every distinct failure reason deserves its own error type. Don't collapse multiple failure modes into generic HTTP errors like NotFoundError or BadRequestError.

  • UserNotFoundError with userId → Frontend shows "User doesn't exist"
  • ChannelNotFoundError with channelId → Frontend shows "Channel was deleted"
  • SessionExpiredError with expiredAt → Frontend shows "Session expired, please log in"

Generic errors lose context and prevent targeted recovery. See references/error-patterns.md for complete patterns including error remapping and retry strategies.

Schema & Branded Types Pattern

Brand all entity IDs for type safety across service boundaries:

import { Schema } from "effect";

// Entity IDs - always branded
export const UserId = Schema.UUID.pipe(Schema.brand("@App/UserId"));
export type UserId = typeof UserId.Type;

export const OrganizationId = Schema.UUID.pipe(
  Schema.brand("@App/OrganizationId"),
);
export type OrganizationId = typeof OrganizationId.Type;

// Domain types - use Schema.Struct
export const User = Schema.Struct({
  id: UserId,
  email: Schema.String,
  name: Schema.String,
  organizationId: OrganizationId,
  createdAt: Schema.DateTimeUtc,
});
export type User = typeof User.Type;

// Input types for mutations
export const CreateUserInput = Schema.Struct({
  email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
  name: Schema.String.pipe(Schema.minLength(1)),
  organizationId: OrganizationId,
});
export type CreateUserInput = typeof CreateUserInput.Type;

When NOT to brand:

  • Simple strings that don't cross service boundaries (URLs, file paths)
  • Primitive config values

See references/schema-patterns.md for transforms and advanced patterns.

Function Pattern with Effect.fn

Always use Effect.fn for service methods. This provides automatic tracing with proper span names:

// ❌ BAD - arrow function returning a generic effect
const findById = (id: UserId) =>
  Effect.gen(function* () {
    // ...
  });

// ✅ CORRECT - Effect.fn with descriptive name
const findById = Effect.fn("UserService.findById")(function* (id: UserId) {
  yield* Effect.annotateCurrentSpan("userId", id);
  const user = yield* repo.findById(id);
  return user;
});

// ✅ CORRECT - Effect.fn with multiple parameters
const transfer = Effect.fn("AccountService.transfer")(function* (
  fromId: AccountId,
  toId: AccountId,
  amount: number,
) {
  yield* Effect.annotateCurrentSpan("fromId", fromId);
  yield* Effect.annotateCurrentSpan("toId", toId);
  yield* Effect.annotateCurrentSpan("amount", amount);
  // ...
});

Layer Composition

Declare dependencies in the service, not at usage sites:

// ✅ CORRECT - dependencies in service definition
export class OrderService extends Effect.Service<OrderService>()(
  "OrderService",
  {
    dependencies: [
      UserService.Default,
      ProductService.Default,
      PaymentService.Default,
    ],
    effect: Effect.gen(function* () {
      const users = yield* UserService;
      const products = yield* ProductService;
      const payments = yield* PaymentService;
      // ...
    }),
  },
) {}

// At app root - simple merge
const AppLive = Layer.mergeAll(
  OrderService.Default,
  // Infrastructure layers (intentionally not in dependencies)
  DatabaseLive,
  RedisLive,
);

See references/layer-patterns.md for testing layers and config-dependent layers.

Option Handling

Never use Option.getOrThrow. Always handle both cases explicitly:

// ✅ CORRECT - explicit handling
yield *
  Option.match(maybeUser, {
    onNone: () =>
      Effect.fail(new UserNotFoundError({ userId, message: "Not found" })),
    onSome: (user) => Effect.succeed(user),
  });

// ✅ CORRECT - with getOrElse for defaults
const name = Option.getOrElse(maybeName, () => "Anonymous");

// ✅ CORRECT - Option.map for transformations
const upperName = Option.map(maybeName, (n) => n.toUpperCase());

Effect Atom (Frontend State)

Effect Atom provides reactive state management for React with Effect integration.

Basic Atoms

import { Atom } from "@effect-atom/atom-react";

// Define atoms OUTSIDE components
const countAtom = Atom.make(0);

// Use keepAlive for global state that should persist
const userPrefsAtom = Atom.make({ theme: "dark" }).pipe(Atom.keepAlive);

// Atom families for per-entity state
const modalAtomFamily = Atom.family((type: string) =>
  Atom.make({ isOpen: false }).pipe(Atom.keepAlive),
);

React Integration

import { useAtomValue, useAtomSet, useAtom, useAtomMount } from "@effect-atom/atom-react"

function Counter() {
    const count = useAtomValue(countAtom)           // Read only
    const setCount = useAtomSet(countAtom)          // Write only
    const [value, setValue] = useAtom(countAtom)    // Read + write

    return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}

// Mount side-effect atoms without reading value
function App() {
    useAtomMount(keyboardShortcutsAtom)
    return <>{children}</>
}

Handling Results with Result.builder

Use Result.builder for rendering effectful atom results. It provides chainable error handling with onErrorTag:

import { Result } from "@effect-atom/atom-react"

function UserProfile() {
    const userResult = useAtomValue(userAtom) // Result<User, Error>

    return Result.builder(userResult)
        .onInitial(() => <div>Loading...</div>)
        .onErrorTag("NotFoundError", () => <div>User not found</div>)
        .onError((error) => <div>Error: {error.message}</div>)
        .onSuccess((user) => <div>Hello, {user.name}</div>)
        .render()
}

Atoms with Side Effects

const scrollYAtom = Atom.make((get) => {
  const onScroll = () => get.setSelf(window.scrollY);

  window.addEventListener("scroll", onScroll);
  get.addFinalizer(() => window.removeEventListener("scroll", onScroll)); // REQUIRED

  return window.scrollY;
}).pipe(Atom.keepAlive);

See references/effect-atom-patterns.md for complete patterns including families, localStorage, and anti-patterns.

RPC & Cluster Patterns

For RPC contracts and cluster workflows, see:

  • references/rpc-cluster-patterns.md - RpcGroup, Workflow.make, Activity patterns

Vercel AI SDK Integration

Use Schema.standardSchemaV1 to bridge Effect schemas to the Vercel AI SDK's inputSchema. For tools with no arguments, use Schema.Record({ key: Schema.String, value: Schema.Never }) — many providers reject empty schemas. When tool execute functions need Effect services, capture the runtime via Effect.runtime<Deps>() in a factory function and use Runtime.runPromise — do not use bare Effect.runPromise with unsatisfied dependencies.

See references/vercel-ai-sdk-patterns.md for complete patterns.

Anti-Patterns (Forbidden)

These patterns are never acceptable:

// FORBIDDEN - runSync/runPromise inside services
const result = Effect.runSync(someEffect); // Never do this

// FORBIDDEN - throw inside Effect.gen
yield *
  Effect.gen(function* () {
    if (bad) throw new Error("No!"); // Use Effect.fail instead
  });

// FORBIDDEN - catchAll losing type info
yield * effect.pipe(Effect.catchAll(() => Effect.fail(new GenericError())));

// FORBIDDEN - console.log
console.log("debug"); // Use Effect.log

// FORBIDDEN - process.env directly
const key = process.env.API_KEY; // Use Config.string("API_KEY")

// FORBIDDEN - null/undefined in domain types
type User = { name: string | null }; // Use Option<string>

// FORBIDDEN - accessors: true in Effect.Service
export class MyService extends Effect.Service<MyService>()("MyService", {
  accessors: true, // Never use accessors
})

See references/anti-patterns.md for the complete list with rationale.

Observability

// Structured logging
yield * Effect.log("Processing order", { orderId, userId, amount });

// Metrics
const orderCounter = Metric.counter("orders_processed");
yield * Metric.increment(orderCounter);

// Config with validation
const config = Config.all({
  port: Config.integer("PORT").pipe(Config.withDefault(3000)),
  apiKey: Config.redacted("API_KEY"),
  maxRetries: Config.integer("MAX_RETRIES").pipe(
    Config.validate({ message: "Must be positive", validation: (n) => n > 0 }),
  ),
});

See references/observability-patterns.md for metrics and tracing patterns.

Reference Files

For detailed patterns, consult these reference files in the references/ directory:

  • service-patterns.md - Service definition, Effect.fn, Context.Tag exceptions, capability-based services
  • error-patterns.md - Schema.TaggedError, error remapping, retry patterns
  • schema-patterns.md - Branded types, transforms, Schema.Class
  • layer-patterns.md - Dependency composition, testing layers, merge vs provide
  • domain-predicates.md - Equivalence, Order, typeclass-derived predicates
  • rpc-cluster-patterns.md - RpcGroup, Workflow, Activity patterns
  • effect-atom-patterns.md - Atom, families, React hooks, Result handling
  • vercel-ai-sdk-patterns.md - Vercel AI SDK tool definitions with Effect Schema
  • anti-patterns.md - Complete list of forbidden patterns
  • observability-patterns.md - Logging, metrics, config patterns
  • effect-test-patterns.md - Testing patterns for effect based applications