skills/stromseng/skills/effect-best-practices

effect-best-practices

SKILL.md

Effect-TS Best Practices

This skill enforces opinionated, consistent patterns for Effect-TS codebases based on makisuo/skills/effect-best-practices and effect-solutions guide and . These patterns optimize for type safety, testability, observability, and maintainability.

Quick Reference: Critical Rules

Category DO DON'T
Services (app) Effect.Service with default implementation Inline dependencies in methods
Services (lib) Context.Tag when no sensible default exists Assuming implementation in library code
Dependencies dependencies: [...] or yield in Layer Pass services as function parameters
Errors Schema.TaggedError (yieldable) Plain classes or generic Error
Error Recovery catchTag/catchTags with pattern matching catchAll losing type info
IDs Schema.String.pipe(Schema.brand("UserId")) Plain string for entity IDs
Functions Effect.fn("Service.method") Anonymous generators
Sequencing Effect.gen with yield* Nested .then() or .pipe() chains
Logging Effect.log with structured data console.log
Config Schema.Config or Config.* primitives process.env directly
Options Option.match with both cases Option.getOrThrow
Nullability Option<T> in domain types null/undefined
Test Layers Layer.sync with in-memory state Mocking frameworks
Atoms Atom.make outside components Creating atoms inside render
Atom Results Result.builder with onErrorTag Ignoring loading/error states

Basics

Effect.gen

Just as async/await provides a sequential, readable way to work with Promise values, Effect.gen and yield* provide the same ergonomic benefits for Effect values:

import { Effect } from "effect"

const program = Effect.gen(function* () {
  const data = yield* fetchData
  yield* Effect.logInfo(`Processing data: ${data}`)
  return yield* processData(data)
})

Effect.fn

Use Effect.fn with generator functions for traced, named effects. Effect.fn traces where the function is called from, not just where it's defined:

import { Effect } from "effect"

const processUser = Effect.fn("processUser")(function* (userId: string) {
  yield* Effect.logInfo(`Processing user ${userId}`)
  const user = yield* getUser(userId)
  return yield* processData(user)
})

Benefits:

  • Call-site tracing for each invocation
  • Stack traces with location details
  • Clean signatures
  • Automatic spans for telemetry

Pipe for Instrumentation

Use .pipe() to add cross-cutting concerns to Effect values:

import { Effect, Schedule } from "effect"

const program = fetchData.pipe(
  Effect.timeout("5 seconds"),
  Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.compose(Schedule.recurs(3)))),
  Effect.tap((data) => Effect.logInfo(`Fetched: ${data}`)),
  Effect.withSpan("fetchData")
)

Common instrumentation:

  • Effect.timeout - fail if effect takes too long
  • Effect.retry - retry on failure with a schedule
  • Effect.tap - run side effect without changing the value
  • Effect.withSpan - add tracing span

Service Definition Pattern

Effect provides two ways to model services: Effect.Service and Context.Tag. Choose based on your use case:

Feature Effect.Service Context.Tag
Best for Application code with clear implementation Library code or dynamically-scoped values
Default impl Required (becomes .Default layer) Optional - supplied later
Boilerplate Less - tag + layer generated More - build layers yourself

Effect.Service (Preferred for App Code)

Use Effect.Service when you have a sensible default implementation:

import { Effect } from "effect"

export class UserService extends Effect.Service<UserService>()("@app/UserService", {
  accessors: true,
  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 automatically wired via .Default
const program = Effect.gen(function* () {
  const user = yield* UserService.findById(userId) // accessors enabled
})

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

The class is the tag: You can provide alternate implementations for testing:

const mock = new UserService({ findById: () => Effect.succeed(mockUser) })
program.pipe(Effect.provideService(UserService, mock))

Context.Tag (For Libraries / No Default)

Use Context.Tag when no sensible default exists or you're writing library code:

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

// Per-request database handle - no sensible global default
class RequestDb extends Context.Tag("@app/RequestDb")<
  RequestDb,
  { readonly query: (sql: string) => Effect.Effect<unknown[]> }
>() {}

// Library code - callers provide implementation
class PaymentGateway extends Context.Tag("@lib/PaymentGateway")<
  PaymentGateway,
  { readonly charge: (amount: number) => Effect.Effect<Receipt, PaymentError> }
>() {}

// Implement with Layer.effect when needed
const RequestDbLive = Layer.effect(
  RequestDb,
  Effect.gen(function* () {
    const pool = yield* DatabasePool
    return RequestDb.of({
      query: (sql) => pool.query(sql)
    })
  })
)

Key rules:

  • Tag identifiers must be unique. Use @path/to/ServiceName prefix pattern
  • Service methods should have no dependencies (R = never)
  • Use readonly properties

See references/service-patterns.md for service-driven development and test layers.

Error Definition Pattern

Use Schema.TaggedError for errors. They are serializable (required for RPC) and yieldable (no need for Effect.fail()):

import { Schema } from "effect"

class UserNotFoundError extends Schema.TaggedError<UserNotFoundError>()(
  "UserNotFoundError",
  {
    userId: UserId,
    userMessage: Schema.String,
  }
) {}

// Usage - yieldable errors can be used directly
const findUser = Effect.fn("findUser")(function* (id: UserId) {
  const user = yield* repo.findById(id)
  if (Option.isNone(user)) {
    return yield* UserNotFoundError.make({ userId: id, userMessage: "User not found" })
  }
  return user.value
})

Error Recovery

Use catchTag/catchTags for type-safe error handling:

// Single error type
yield* repo.findById(id).pipe(
  Effect.catchTag("DatabaseError", (err) =>
    UserLookupError.make({ userId: id, reason: err.reason, cause: err })
  )
)

// Multiple error types
yield* effect.pipe(
  Effect.catchTags({
    DatabaseError: (err) => UserLookupError.make({ userId: id, reason: err.reason, cause: err }),
    ValidationError: (err) => InvalidInputError.make({ field: err.field, reason: err.reason, cause: err }),
  })
)

Schema.Defect for Unknown Errors

Wrap errors from external libraries with Schema.Defect:

class ApiError extends Schema.TaggedError<ApiError>()(
  "ApiError",
  {
    endpoint: Schema.String,
    statusCode: Schema.Number,
    error: Schema.Defect, // Wraps unknown errors
  }
) {}

See references/error-patterns.md for expected vs defects and retry patterns.

Data Modeling

Branded Types

Brand all entity IDs to prevent mixing values with the same underlying type:

import { Schema } from "effect"

export const UserId = Schema.String.pipe(Schema.brand("UserId"))
export type UserId = typeof UserId.Type

export const PostId = Schema.String.pipe(Schema.brand("PostId"))
export type PostId = typeof PostId.Type

// Type error: can't pass PostId where UserId expected
function getUser(id: UserId) { /* ... */ }
getUser(PostId.make("post-123")) // Error!

Schema.Class for Records

Use Schema.Class for composite data models:

export class User extends Schema.Class<User>("User")({
  id: UserId,
  name: Schema.String,
  email: Schema.String,
  createdAt: Schema.Date,
}) {
  get displayName() {
    return `${this.name} (${this.email})`
  }
}

Schema.TaggedClass for Variants

Use Schema.TaggedClass with Schema.Union for discriminated unions:

import { Match, Schema } from "effect"

export class Success extends Schema.TaggedClass<Success>()("Success", {
  value: Schema.Number,
}) {}

export class Failure extends Schema.TaggedClass<Failure>()("Failure", {
  error: Schema.String,
}) {}

export const Result = Schema.Union(Success, Failure)

// Pattern match with Match.valueTags
Match.valueTags(result, {
  Success: ({ value }) => `Got: ${value}`,
  Failure: ({ error }) => `Error: ${error}`
})

See references/schema-patterns.md for JSON encoding and advanced patterns.

Layer Composition & Memoization

Provide layers once at the top of your application:

const appLayer = userServiceLayer.pipe(
  Layer.provideMerge(databaseLayer),
  Layer.provideMerge(loggerLayer)
)

const main = program.pipe(Effect.provide(appLayer))
Effect.runPromise(main)

Layer Memoization Warning

Effect memoizes layers by reference identity. Store parameterized layers in constants:

// BAD: creates TWO connection pools
const badLayer = Layer.merge(
  UserRepo.layer.pipe(Layer.provide(Postgres.layer({ url: "..." }))),
  OrderRepo.layer.pipe(Layer.provide(Postgres.layer({ url: "..." }))) // Different reference!
)

// GOOD: single connection pool
const postgresLayer = Postgres.layer({ url: "..." })
const goodLayer = Layer.merge(
  UserRepo.layer.pipe(Layer.provide(postgresLayer)),
  OrderRepo.layer.pipe(Layer.provide(postgresLayer)) // Same reference!
)

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

Config

Use Config.* primitives or Schema.Config for type-safe configuration:

import { Config, Effect, Schema } from "effect"

const Port = Schema.Int.pipe(Schema.between(1, 65535))

const program = Effect.gen(function* () {
  // Basic primitives
  const apiKey = yield* Config.redacted("API_KEY")
  const port = yield* Config.integer("PORT")

  // With Schema validation
  const validatedPort = yield* Schema.Config("PORT", Port)
})

Config Service Pattern

Create config services with test layers:

class ApiConfig extends Context.Tag("@app/ApiConfig")<
  ApiConfig,
  { readonly apiKey: Redacted.Redacted; readonly baseUrl: string }
>() {
  static readonly layer = Layer.effect(
    ApiConfig,
    Effect.gen(function* () {
      const apiKey = yield* Config.redacted("API_KEY")
      const baseUrl = yield* Config.string("API_BASE_URL")
      return ApiConfig.of({ apiKey, baseUrl })
    })
  )

  // For tests - hardcoded values
  static readonly testLayer = Layer.succeed(ApiConfig, {
    apiKey: Redacted.make("test-key"),
    baseUrl: "https://test.example.com"
  })
}

See references/config-patterns.md for ConfigProvider and advanced patterns.

Testing

Use @effect/vitest for Effect-native testing:

import { Effect } from "effect"
import { describe, expect, it } from "@effect/vitest"

describe("Calculator", () => {
  it.effect("adds numbers", () =>
    Effect.gen(function* () {
      const result = yield* Effect.succeed(1 + 1)
      expect(result).toBe(2)
    })
  )

  // With scoped resources
  it.scoped("cleans up resources", () =>
    Effect.gen(function* () {
      const tempDir = yield* fs.makeTempDirectoryScoped()
      // tempDir deleted when scope closes
    })
  )
})

Test Layers

Create in-memory test layers with Layer.sync:

class Users extends Context.Tag("@app/Users")<Users, { /* ... */ }>() {
  static readonly testLayer = Layer.sync(Users, () => {
    const store = new Map<UserId, User>()

    const create = (user: User) => Effect.sync(() => void store.set(user.id, user))
    const findById = (id: UserId) => Effect.fromNullable(store.get(id))

    return Users.of({ create, findById })
  })
}

See references/testing-patterns.md for TestClock and worked examples.

CLI

Use @effect/cli for typed argument parsing:

import { Args, Command, Options } from "@effect/cli"
import { BunContext, BunRuntime } from "@effect/platform-bun"
import { Console, Effect } from "effect"

const name = Args.text({ name: "name" }).pipe(Args.withDefault("World"))
const shout = Options.boolean("shout").pipe(Options.withAlias("s"))

const greet = Command.make("greet", { name, shout }, ({ name, shout }) => {
  const message = `Hello, ${name}!`
  return Console.log(shout ? message.toUpperCase() : message)
})

const cli = Command.run(greet, { name: "greet", version: "1.0.0" })

cli(process.argv).pipe(Effect.provide(BunContext.layer), BunRuntime.runMain)

See references/cli-patterns.md for subcommands and service integration.

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 } 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>
}

Handling Results with Result.builder

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

function UserProfile() {
  const userResult = useAtomValue(userAtom)

  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()
}

See references/effect-atom-patterns.md for complete patterns.

Anti-Patterns (Forbidden)

// FORBIDDEN - runSync/runPromise inside services
const result = Effect.runSync(someEffect)

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

// 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>

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

Reference Files

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

  • service-patterns.md - Effect.Service vs Context.Tag, dependencies, test layers
  • error-patterns.md - Schema.TaggedError, yieldable errors, Schema.Defect
  • schema-patterns.md - Branded types, Schema.Class, JSON encoding
  • layer-patterns.md - Dependency composition, memoization, testing layers
  • config-patterns.md - Config primitives, Schema.Config, ConfigProvider
  • testing-patterns.md - @effect/vitest, it.effect, it.scoped, TestClock
  • cli-patterns.md - @effect/cli Commands, Args, Options, subcommands
  • effect-atom-patterns.md - Atom, families, React hooks, Result handling
  • anti-patterns.md - Complete list of forbidden patterns
  • observability-patterns.md - Logging, metrics, config patterns
  • rpc-cluster-patterns.md - RpcGroup, Workflow, Activity patterns
Weekly Installs
1
GitHub Stars
2
First Seen
Feb 4, 2026
Installed on
replit1
amp1
opencode1
kimi-cli1
codex1
github-copilot1