Effect Solutions

repository·main·Indexed 18 days ago

https://github.com/kitlangton/effect-solutions

A curated collection of opinionated, type-safe patterns and best practices for the Effect TypeScript ecosystem. It includes a web-based documentation site and a CLI for offline access to setup guides, configuration examples, and pattern implementations, such as error handling with Effect.catchTag and HTTP client setup with FetchHttpClient.

Tokens
33.2K
Snippets
100
Records
110
Agent score
63%

What's inside effect-solutions

  1. What is a Service in Effect?

    main

    A Service is a contract that defines an interface for a specific capability without providing an implementation. In Effect, services are defined using Context.Service as classes. This allows you to write business logic that depends on these interfaces, ensuring your code is decoupled from specific implementations (like a real database vs. a mock database).

    Key Rules for Services:

    • Unique Identifiers: Every service must have a unique tag identifier (e.g., @app/Database). It is recommended to use a prefix pattern like @path/to/ServiceName to avoid collisions.
    • No Method Dependencies: Service methods should not take dependencies as arguments (they should have R = never). Instead, dependencies are resolved via Layer composition.
    • Immutability: Use readonly properties for service methods to prevent exposing mutable state directly.
    import { Effect } from "effect"
    import * as Context from "effect/Context"
    
    class Database extends Context.Service<
      Database,
      {
        readonly query: (sql: string) => Effect.Effect<unknown[]>
        readonly execute: (sql: string) => Effect.Effect<void>
      }
    >()("@app/Database") {}
  2. What is a Layer in Effect?

    main

    A Layer is the concrete implementation of one or more Services. While a Service defines what can be done, a Layer defines how it is done. Layers are responsible for:

    1. Setup/Initialization: Performing tasks like connecting to a database or reading configuration files.
    2. Dependency Resolution: Acquiring and providing other services that the current service requires to function.
    3. Resource Lifecycle: Managing the lifecycle of resources (e.g., closing connections) automatically.

    Naming Convention: It is recommended to use camelCase with a Layer suffix for layer constants (e.g., layer, testLayer, postgresLayer).

    // Example of a service providing its own implementation via a static layer property
    class Users extends Context.Service<Users, { readonly all: () => Effect.Effect<readonly User[]> }>()("@app/Users") {
      static readonly layer = Layer.effect(
        Users,
        Effect.gen(function* () {
          // Implementation logic here
          return { all: () => Effect.succeed([]) }
        })
      )
    }
  3. How Effect Config works and how to provide sources

    main

    Effect's Config module provides type-safe configuration loading. By default, it loads from environment variables. You can swap the source (e.g., for testing or development) by providing a ConfigProvider via ConfigProvider.layer.

    Common sources include:

    • Production: Environment variables (default).
    • Tests: In-memory maps using ConfigProvider.fromUnknown.
    • Development: JSON files or hardcoded values.
    • Prefixed: Using ConfigProvider.nested to read variables with a specific prefix (e.g., APP_PORT instead of PORT).
    import { Config, ConfigProvider, Effect, Layer } from "effect"
    
    const program = Effect.gen(function* () {
      const apiKey = yield* Config.redacted("API_KEY")
      const port = yield* Config.int("PORT")
      console.log(`Starting server on port ${port}`)
    })
    
    // Use a different config source for testing
    const testConfigProvider = ConfigProvider.fromUnknown({
      API_KEY: "test-key-123",
      PORT: "3000",
    })
    
    const testConfigLayer = ConfigProvider.layer(testConfigProvider)
    
    // Run with test config
    Effect.runPromise(program.pipe(Effect.provide(testConfigLayer)))
  4. Understand the difference between Expected Errors and Defects

    main

    Effect distinguishes between errors that are part of the domain logic and unrecoverable system failures.

    Expected Errors

    Use typed errors (tracked in Effect<A, E, R>) for domain failures that the caller can reasonably handle. Examples include:

    • Validation errors
    • "Not found" scenarios
    • Permission denied
    • Rate limits

    Defects

    Use defects for unrecoverable situations where there is no sensible way to recover. Defects terminate the fiber and should typically be handled once at the system boundary (e.g., for logging or graceful shutdown). Examples include:

    • Software bugs
    • Invariant violations

    To convert an error into a defect (terminating the fiber), use Effect.orDie.

    import { Effect } from "effect"
    // hide-start
    declare const loadConfig: Effect.Effect<{ port: number }, Error>
    // hide-end
    
    // At app entry: if config fails, nothing can proceed
    const main = Effect.gen(function* () {
      const config = yield* loadConfig.pipe(Effect.orDie)
      yield* Effect.log(`Starting on port ${config.port}`)
    })
  5. Model alternatives with Variants (OR Types)

    main

    Variants (sum types) represent data that can be one of several different structures.

    1. Simple Alternatives: Use Schema.Literals for a fixed set of primitive values (e.g., strings or numbers).
    2. Structured Variants: Combine Schema.TaggedClass with Schema.Union. Each class in the union should be a TaggedClass to allow for type-safe pattern matching using Match.value and Match.tag.
    import { Match, Schema } from "effect"
    
    // 1. Simple Literals
    const Status = Schema.Literals(["pending", "active", "completed"])
    
    // 2. Structured Variants
    export class Success extends Schema.TaggedClass<Success>("Success")("Success", {
      value: Schema.Number,
    }) {}
    
    export class Failure extends Schema.TaggedClass<Failure>("Failure")("Failure", {
      error: Schema.String,
    }) {}
    
    export const Result = Schema.Union([Success, Failure])
    export type Result = typeof Result.Type
    
    // Pattern matching
    const renderResult = (result: Result) =>
      Match.value(result).pipe(
        Match.tag("Success", ({ value }) => `Got: ${value}`),
        Match.tag("Failure", ({ error }) => `Error: ${error}`),
        Match.exhaustive,
      )
  6. Interop between Effect and Promises

    main

    When introducing Effect into an existing codebase, you can bridge the gap between standard JavaScript Promises and the Effect type using the following patterns:

    Wrapping Promises into Effects

    Use these functions to convert existing asynchronous operations into Effect values:

    • Effect.tryPromise: Wraps a promise-returning function, capturing potential rejections as errors within the Effect.
    • Effect.promise: Wraps a promise that is expected to succeed (or where you handle errors differently).

    Running Effects as Promises

    To execute an Effect from a non-Effect environment (like a standard async function or a framework handler), use these runners:

    • Effect.runPromise: Executes the effect and returns a Promise that resolves with the success value or rejects with the error.
    • Effect.runPromiseExit: Executes the effect and returns a Promise that resolves with an Exit object, which contains either the success value or the failure details, preventing unhandled rejections.
  7. Difference between it.effect() and it.live()

    main

    Choosing between it.effect() and it.live() depends on whether you need a deterministic environment or real-world behavior:

    • it.effect(): Provides a TestContext. The Clock is a TestClock that starts at time 0. This is used for deterministic testing where you can manually advance time.
    • it.live(): Uses the real system environment. The Clock uses the actual system clock, and logging is enabled by default. Use this when you need to test actual delays or real-world timing behavior.
    import { Clock, Effect } from "effect"
    
    // it.effect provides TestContext - clock starts at 0
    it.effect("test clock starts at zero", () =>
      Effect.gen(function* () {
        const now = yield* Clock.currentTimeMillis
        expect(now).toBe(0)
      })
    )
    
    // it.live uses real system clock
    it.live("real clock", () =>
      Effect.gen(function* () {
        const now = yield* Clock.currentTimeMillis
        expect(now).toBeGreaterThan(0) // Actual system time
      })
    )
  8. Gradual service introduction: Functions vs Services

    main

    When migrating to Effect, you don't need to use Dependency Injection (DI) immediately. Follow this progression:

    1. Start with plain functions: Use Effect simply for its error handling and async capabilities. Write functions that return Effect<R, E, A> but don't necessarily rely on complex environment requirements (R).
    2. Upgrade to Services: Once the logic is stable, move shared state, complex dependencies, or composition logic into formal Services. This allows you to leverage Effect's powerful Dependency Injection (DI) and Layer system for better modularity and testability.
  9. Wrap external libraries using Tags and Layers

    main

    To integrate external, non-Effect libraries (such as callback-based APIs) into your system, use the Tag + Layer pattern. This allows you to treat the external library as a managed service within the Effect ecosystem.

    1. Define a Tag: Create a Tag that represents the service interface.
    2. Implement a Layer: Use Layer.succeed (for synchronous values) or Layer.effect (for asynchronous setup) to wrap the external library's functionality into a Layer that provides the service defined by your Tag.
  10. Avoid duplicate resource creation with Layer memoization

    main

    Effect memoizes layers based on their reference identity. If the same layer instance is used multiple times in a dependency graph, Effect constructs it only once.

    When using parameterized layer constructors (e.g., Postgres.layer({ ... })), calling the constructor multiple times creates multiple distinct instances. This can lead to resource exhaustion, such as creating multiple database connection pools when only one was intended.

    The Rule: Always store the result of a parameterized layer constructor in a constant before using it in multiple places to ensure the same reference is shared.

    // ❌ Bad: Calling the constructor twice creates two different references/pools
    const badAppLayer = Layer.merge(
      UserRepo.layer.pipe(Layer.provide(Postgres.layer({ url: "...", poolSize: 10 }))),
      OrderRepo.layer.pipe(Layer.provide(Postgres.layer({ url: "...", poolSize: 10 }))) // Different reference!
    )
    
    // ✅ Good: Store the layer in a constant to share the same reference
    const postgresLayer = Postgres.layer({ url: "postgres://localhost/mydb", poolSize: 10 })
    
    const goodAppLayer = Layer.merge(
      UserRepo.layer.pipe(Layer.provide(postgresLayer)),
      OrderRepo.layer.pipe(Layer.provide(postgresLayer)) // Same reference!
    )
  11. How the Service `use` pattern works

    main

    The use pattern is a design pattern for wrapping third-party, Promise-based libraries into Effect services. Instead of exposing the library client directly, the service exposes a single use method that accepts a callback. This callback provides the underlying library instance and an AbortSignal.

    Benefits of this pattern:

    1. Automatic error wrapping: All errors thrown by the library are caught and converted into a specific, type-safe Effect error (e.g., using Schema.TaggedErrorClass).
    2. Interruption support: The AbortSignal is automatically passed to the library. When the Effect is interrupted, the signal is aborted, allowing the underlying library to perform proper cancellation/cleanup.
    3. Encapsulation: It prevents consumers from using the library outside of the managed Effect context.

    When to use it: Use this pattern for libraries with many methods that support AbortSignal (e.g., Prisma, Drizzle, AWS SDK, Supabase, or node:fs/promises). For libraries with only a few methods, it is often simpler to wrap each method individually using Effect.tryPromise.

    // The core signature of the pattern
    class MyService extends Context.Service<MyService, {
      readonly use: <A>(fn: (client: typeof Library, signal: AbortSignal) => Promise<A>) => Effect.Effect<A, MyError>
    }>()("MyService" {
      // Implementation details...
    })