Skip to main content

Overview

Hazel Chat uses Effect-TS extensively throughout the backend and cluster services. Effect-TS is a functional programming framework for TypeScript that provides:
  • Type-safe error handling with typed errors
  • Dependency injection via layers and services
  • Resource management with automatic cleanup
  • Composable effects that can be combined and transformed
  • Concurrency primitives like fibers and queues
Effect-TS brings the power of functional programming to TypeScript without sacrificing type safety or developer experience.

Core Concepts

Effect Type

The Effect type represents a computation that:
  • Produces a value of type A (success)
  • May fail with an error of type E
  • Requires dependencies of type R
import { Effect } from "effect"

//        ┌─── Success type (User)
//        │        ┌─── Error types
//        │        │                            ┌─── Dependencies
//        │        │                            │
//        ▼        ▼                            ▼
Effect<User, UserNotFoundError | DatabaseError, Database>

Effect.gen - Generator Syntax

Effect uses generator syntax (function*) for sequential async operations:
import { Effect } from "effect"
import { UserRepo } from "@hazel/backend-core"

const getUser = (userId: UserId) =>
  Effect.gen(function* () {
    // yield* unwraps Effect values
    const user = yield* UserRepo.findById(userId)
    
    if (Option.isNone(user)) {
      return yield* Effect.fail(new UserNotFoundError({ userId }))
    }
    
    return user.value
  })

Service Pattern

Defining Services with Effect.Service

Always use Effect.Service instead of Context.Tag for services:
import { Effect } from "effect"
import { Database } from "@hazel/db"

// ✅ CORRECT - Use Effect.Service
export class UserService extends Effect.Service<UserService>()("UserService", {
  accessors: true,
  dependencies: [Database.Default],
  effect: Effect.gen(function* () {
    const db = yield* Database
    
    return {
      getUser: (userId: UserId) =>
        Effect.gen(function* () {
          const user = yield* db.execute((client) =>
            client
              .select()
              .from(schema.usersTable)
              .where(eq(schema.usersTable.id, userId))
          )
          return user[0]
        }),
      
      createUser: (data: UserInsert) =>
        Effect.gen(function* () {
          const result = yield* db.execute((client) =>
            client.insert(schema.usersTable).values(data).returning()
          )
          return result[0]
        }),
    }
  }),
}) {}
Key features:
  • accessors: true - Auto-generates accessor methods
  • dependencies - Declares required services
  • effect - Implementation with dependency access

Using Services

import { Effect } from "effect"
import { UserService } from "./user-service"

const program = Effect.gen(function* () {
  // Access service via yield*
  const userService = yield* UserService
  
  // Call service methods
  const user = yield* userService.getUser(userId)
  
  return user
})
The accessors: true option generates static methods, allowing you to use yield* UserService instead of yield* Effect.serviceConstants(UserService).

Dependency Injection with Layers

Creating Layers

Layers provide implementations for services:
import { Layer, Effect, Config } from "effect"
import { Database } from "@hazel/db"

// Database layer from environment config
export const DatabaseLive = Layer.unwrapEffect(
  Effect.gen(function* () {
    const dbUrl = yield* Config.redacted("DATABASE_URL")
    
    return Database.layer({
      url: dbUrl,
      ssl: true,
    })
  }),
)

Layer Composition

Combine multiple layers with Layer.mergeAll:
import { Layer } from "effect"
import { UserRepo, MessageRepo, ChannelRepo } from "@hazel/backend-core"
import { DatabaseLive } from "./database"

// Merge all repository layers
const RepoLive = Layer.mergeAll(
  UserRepo.Default,
  MessageRepo.Default,
  ChannelRepo.Default,
)

// Main application layer
const MainLive = Layer.mergeAll(
  RepoLive,
  DatabaseLive,
)

Providing Layers

Provide layers to effects that need them:
import { Effect, Layer } from "effect"

const program = Effect.gen(function* () {
  const userRepo = yield* UserRepo
  const user = yield* userRepo.findById(userId)
  return user
})

// Provide dependencies
const runnable = program.pipe(Layer.provide(MainLive))

// Execute the effect
Effect.runPromise(runnable)

Layer Benefits

Layers enable dependency injection, making code testable and modular.

Automatic Wiring

Effect automatically resolves dependencies in the layer graph.

Error Handling

Defining Typed Errors

Use Schema.TaggedError for typed errors:
import { Schema } from "effect"

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

export class DatabaseError extends Schema.TaggedError<DatabaseError>()("DatabaseError", {
  cause: Schema.Unknown,
  message: Schema.String,
}) {}

Handling Errors with catchTag

Always prefer catchTag over catchAll to preserve error types:
import { Effect } from "effect"

// ✅ CORRECT - catchTag preserves error types
const program = Effect.gen(function* () {
  return yield* fetchUser(userId).pipe(
    Effect.catchTag("UserNotFoundError", (err) =>
      Effect.succeed(null)  // Return null for not found
    ),
    Effect.catchTag("DatabaseError", (err) =>
      Effect.fail(new InternalServerError({ cause: err }))
    ),
  )
})

// ❌ WRONG - catchAll loses error type information
const badProgram = Effect.gen(function* () {
  return yield* fetchUser(userId).pipe(
    Effect.catchAll((err) =>
      Effect.fail(new InternalServerError())  // Lost specific error info!
    ),
  )
})

Error Unions

Combine multiple error types:
import { Schema } from "effect"

export const MessageErrors = Schema.Union(
  MessageNotFoundError,
  UnauthorizedError,
  RateLimitExceededError,
  InternalServerError,
)

type MessageErrors = Schema.Schema.Type<typeof MessageErrors>

Transaction Patterns

Automatic Transaction Context

The database layer provides automatic transaction propagation:
import { Effect } from "effect"
import { Database } from "@hazel/db"
import { UserRepo, OrganizationRepo } from "@hazel/backend-core"

const createUserWithOrg = (userData: UserInsert, orgData: OrgInsert) =>
  Effect.gen(function* () {
    const db = yield* Database
    
    return yield* db.transaction(
      Effect.gen(function* () {
        // Both operations share the same transaction automatically
        const user = yield* UserRepo.insert(userData)
        const org = yield* OrganizationRepo.insert({
          ...orgData,
          ownerId: user.id,
        })
        
        return { user, org }
      }),
    )
  })
No need to manually pass transaction clients - Effect’s Context system handles it automatically.

Real-World Examples

Backend Service Example

From apps/backend/src/services/session-manager.ts:
import { Effect } from "effect"
import { ResultPersistence } from "@effect/platform"
import { WorkOSAuth } from "./workos-auth"
import { UserRepo } from "@hazel/backend-core"

export class SessionManager extends Effect.Service<SessionManager>()("SessionManager", {
  accessors: true,
  dependencies: [
    WorkOSAuth.Default,
    UserRepo.Default,
    ResultPersistence.Default,
  ],
  effect: Effect.gen(function* () {
    const workos = yield* WorkOSAuth
    const userRepo = yield* UserRepo
    const cache = yield* ResultPersistence
    
    const authenticateWithBearer = (token: string) =>
      Effect.gen(function* () {
        // Check cache first
        const cached = yield* cache.get(`session:${token}`).pipe(
          Effect.option,
        )
        
        if (Option.isSome(cached)) {
          return cached.value
        }
        
        // Verify with WorkOS
        const session = yield* workos.verifySession(token)
        const user = yield* userRepo.findById(session.userId)
        
        if (Option.isNone(user)) {
          return yield* Effect.fail(new UnauthorizedError())
        }
        
        // Cache for 5 minutes
        yield* cache.set(`session:${token}`, user.value, 300)
        
        return user.value
      })
    
    return { authenticateWithBearer }
  }),
}) {}

RPC Handler Example

From apps/backend/src/rpc/messages.ts:
import { Rpc } from "@effect/rpc"
import { Effect } from "effect"
import { MessageRepo, ChannelRepo } from "@hazel/backend-core"
import { MessagePolicy } from "../policies/message-policy"
import { policyUse } from "@hazel/backend-core"

export const messageCreate = Rpc.effect(
  Rpc.Messages.MessageCreate,
  (payload) =>
    Effect.gen(function* () {
      const messageRepo = yield* MessageRepo
      const channelRepo = yield* ChannelRepo
      const currentUser = yield* CurrentUser
      
      // Verify channel exists
      const channel = yield* channelRepo.findById(payload.channelId)
      if (Option.isNone(channel)) {
        return yield* Effect.fail(new ChannelNotFoundError())
      }
      
      // Create message with policy check
      const message = yield* messageRepo.insert({
        ...payload,
        authorId: currentUser.id,
      }).pipe(
        policyUse(MessagePolicy.canCreate(payload.channelId)),
      )
      
      return { data: message, transactionId: yield* generateTransactionId() }
    }),
)

Repository Pattern Example

From packages/backend-core/src/repositories/message-repo.ts:
import { Effect } from "effect"
import { Database, ModelRepository, schema } from "@hazel/db"
import { Message } from "@hazel/domain/models"

export class MessageRepo extends Effect.Service<MessageRepo>()("MessageRepo", {
  accessors: true,
  dependencies: [Database.Default],
  effect: Effect.gen(function* () {
    const db = yield* Database
    
    // Base repository with standard CRUD operations
    const baseRepo = yield* ModelRepository.makeRepository(
      schema.messagesTable,
      Message.Model,
      { idColumn: "id", name: "Message" },
    )
    
    // Custom query methods
    const findByChannel = (channelId: ChannelId, limit = 50, tx?: TxFn) =>
      db.makeQuery(
        (execute) =>
          execute((client) =>
            client
              .select()
              .from(schema.messagesTable)
              .where(eq(schema.messagesTable.channelId, channelId))
              .orderBy(desc(schema.messagesTable.createdAt))
              .limit(limit)
          ),
        policyRequire("Message", "select"),
      )({ channelId }, tx)
    
    return {
      ...baseRepo,
      findByChannel,
    }
  }),
}) {}

Concurrency Patterns

Running Effects in Parallel

import { Effect } from "effect"

const program = Effect.gen(function* () {
  // Run multiple effects in parallel
  const [users, channels, messages] = yield* Effect.all([
    userRepo.findAll(),
    channelRepo.findAll(),
    messageRepo.findAll(),
  ], { concurrency: "unbounded" })
  
  return { users, channels, messages }
})

Racing Effects

import { Effect, Duration } from "effect"

const withTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>, ms: number) =>
  Effect.race(
    effect,
    Effect.sleep(Duration.millis(ms)).pipe(
      Effect.flatMap(() => Effect.fail(new TimeoutError())),
    ),
  )

const result = yield* withTimeout(fetchData(), 5000)

Testing with Effect

Mock Layers for Testing

import { Effect, Layer } from "effect"
import { UserRepo } from "@hazel/backend-core"

const UserRepoMock = Layer.succeed(UserRepo, {
  findById: (userId) =>
    Effect.succeed(Option.some({
      id: userId,
      name: "Test User",
      email: "[email protected]",
    })),
  insert: (data) =>
    Effect.succeed({
      id: "test-id",
      ...data,
    }),
})

// Use in tests
const testProgram = program.pipe(Layer.provide(UserRepoMock))

Best Practices

Use Effect.Service

Always use Effect.Service instead of Context.Tag for service definitions.

Declare Dependencies

Always declare dependencies in the dependencies array to avoid leaked deps.

Use catchTag

Prefer catchTag over catchAll to preserve error type information.

Typed Errors

Use Schema.TaggedError for all custom errors with structured data.

Generator Syntax

Use Effect.gen(function* () {}) for sequential operations.

Layer Composition

Compose layers with Layer.mergeAll for clean dependency graphs.

Additional Resources

For more Effect-TS patterns and examples, see the .context/effect/ directory in the repository.

Next Steps

Database Package

Learn about the database layer and transaction patterns

RPC System

Explore the type-safe RPC system built on Effect RPC

Build docs developers (and LLMs) love