agentleFS
betalyrabetalyra/effect-skills/effect-best-practices/SKILL.md

effect-best-practices

Skill20 starsChanged 7 months agoMITView on GitHub

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.

  • Reads credentials

5 other projects use a version of this, 5 of them edited.

Adopted by

Most recently taken up by

  • Other versions5 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 5

    1. millionco/expect★ 3.6k80% same
    2. forcedotcom/salesforcedx-vscode★ 1k28% same
    3. Makisuo/skills★ 7964% same
    4. niieani/caara★ 464% same
    5. jliocsar/parley★ 067% same

    Wrote their own version · 0

    None found.

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

The file

We split this skill into the rules it gives an agent

5 rules. 0 are common, 3 are distinctive, and 2 are very specific to this project.

  • 0

    Common

    Most of the 5 edited versions we read keep these.

  • 3

    Distinctive

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    ALWAYS: prefer data-first pipe style for composition:

    line 24
  2. Distinctive:

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

    line 47
  3. Distinctive:

    Instead just import from "effect" package.

    line 48
  4. Very specific:

    Every distinct failure reason deserves its own error type: Don't collapse multiple failure modes into generic HTTP errors like …

    line 191
  5. Very specific:

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

    line 308

“Common” is measured: kept by at least 6 in 10 of the 5 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

CategoryDODON'T
ServicesEffect.ServiceContext.Tag for business logic
Dependenciesdependencies: [Dep.Default] at the top of the serviceManual Layer.provide at usage sites
ErrorsSchema.TaggedError with message and cause fieldsPlain classes or generic Error
Error SpecificityUserNotFoundError, SessionExpiredErrorGeneric NotFoundError, BadRequestError
Error HandlingcatchTag (supports multiple tags) / catchTagscatchAll or mapError
IDsSchema.UUID.pipe(Schema.brand("@App/EntityId"))Plain string for entity IDs
FunctionsEffect.fn("Service.method")Anonymous generators
LoggingEffect.log with structured dataconsole.log
ConfigConfig.* with validationprocess.env directly
OptionsOption.match with both casesOption.getOrThrow
NullabilityOption<T> in domain typesnull/undefined
AtomsAtom.make outside componentsCreating atoms inside render
Atom StateAtom.keepAlive for global stateForgetting keepAlive for persistent state
Atom UpdatesuseAtomSet in React componentsAtom.update imperatively from React
Atom Cleanupget.addFinalizer() for side effectsMissing cleanup for event listeners
Atom ResultsResult.builder with onErrorTagIgnoring 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), provides consistent structure, and makes them yieldable — no Effect.fail() wrapper needed since TaggedError instances implement the Effect interface directly.

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 - single tag
yield* repo.findById(id).pipe(
    Effect.catchTag("DatabaseError", (err) =>
      new UserNotFoundError({ userId: id, message: "Lookup failed" }),
    ),
  );

// ✅ CORRECT - multiple tags, same handler (catchTag accepts variadic tags)
yield* effect.pipe(
    Effect.catchTag(
      "TokenExpiredError",
      "TokenInvalidError",
      "MissingTokenError",
      () => new AuthError({ message: "Authentication failed" }),
    ),
  );

// ✅ CORRECT - multiple tags, different handlers (use catchTags)
yield* effect.pipe(
    Effect.catchTags({
      DatabaseError: (err) =>
        new UserNotFoundError({ userId: id, message: err.message }),
      ValidationError: (err) =>
        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: () => 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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.