Skip to main content

Effect TS

Diminuendo is built entirely on Effect, a TypeScript library that replaces ad-hoc error handling, manual dependency wiring, and unstructured concurrency with a principled, type-safe programming model. This page covers both the strategic rationale and the concrete patterns — the why and the how in one place.

The Case for Effect

TypeScript is a remarkable type system bolted onto a language that was never designed for type safety. It catches shape errors at compile time — wrong field names, missing properties, incompatible types — but it is structurally blind to four categories of bugs that account for the majority of production incidents in backend services:

Invisible Errors

Functions throw exceptions that are not in their type signatures. fetchUser(id) might throw a network error, a parse error, or a 404 — but the return type says Promise<User>. Callers must remember to catch, and TypeScript cannot verify that they do.

Hidden Dependencies

Services import singletons directly: import { db } from "./database". This makes every consumer implicitly dependent on the entire module graph. Unit testing requires mocking module imports. Swapping implementations requires changing every import site.

Leaked Resources

Database connections, file handles, WebSocket streams, and timers must be manually closed in finally blocks. Miss one, and you get a slow memory leak that manifests as an OOM crash three days later. Compose two resources, and the cleanup logic becomes nested and error-prone.

Orphaned Concurrency

Promise.all([a, b, c]) starts three tasks, but if a fails, b and c keep running. There is no cancellation. Background setTimeout callbacks fire after the request is gone. Orphaned promises silently swallow errors.
Effect solves all four problems in a single, composable abstraction: Effect<Success, Error, Requirements>. The three type parameters make success values, failure modes, and dependencies explicit at the type level.

Layers as Dependency Injection

Every service in Diminuendo is defined as a Context.Tag — a typed key that identifies a service interface — with a corresponding Live implementation wrapped in a Layer.
// src/auth/AuthService.ts
export class AuthService extends Context.Tag("AuthService")<
  AuthService,
  { readonly authenticate: (token: string | null) => Effect.Effect<AuthIdentity, Unauthenticated> }
>() {}

export const AuthServiceLive = Layer.effect(
  AuthService,
  Effect.gen(function* () {
    const config = yield* AppConfig  // Dependency declared, not imported
    if (config.devMode) {
      return {
        authenticate: (_token) => Effect.succeed({
          userId: "dev-user-001",
          email: "developer@example.com",
          tenantId: "dev",
        }),
      }
    }
    // Production: JWT verification via JWKS endpoint ...
  })
)
The key insight is that AuthServiceLive does not import AppConfig as a module — it requires it as a Layer dependency. The Effect runtime resolves this dependency at startup via the Layer graph.

Composing the Layer Graph

In src/main.ts, all 24 service layers are composed into a single dependency tree:
const RouterDeps = Layer.mergeAll(
  RegistryLayer, PodiumLayer, AppConfigLive,
  BroadcastLayer, BillingLayer, WorkerLayer, MembershipLayer,
  ThreadNamingLayer, AutomationStoreLayer, AutomationEngineLayer, MemoryLayer,
)
const RouterLayer = MessageRouterLive.pipe(Layer.provide(RouterDeps))
Layer.mergeAll combines independent layers in parallel. Layer.provide chains dependent layers in sequence. If a layer requires a dependency that is not provided, it is a compile-time type error.
Concrete impact: The AutomationEngine depends on 6 services. In raw TypeScript, these would be 6 constructor parameters or 6 module imports. With Effect, they are 6 type-level requirements that the Layer graph verifies at compile time. Add a new dependency, and every consumer that doesn’t provide it fails to compile.

Typed Errors

Diminuendo uses Data.TaggedError to define a closed set of 14 domain errors. Each error is a branded class with a _tag discriminant:
export class Unauthenticated extends Data.TaggedError("Unauthenticated")<{ readonly reason: string }> {}
export class SessionNotFound extends Data.TaggedError("SessionNotFound")<{ readonly sessionId: string }> {}
export class PodiumConnectionError extends Data.TaggedError("PodiumConnectionError")<{ readonly message: string; readonly cause?: unknown }> {}
These errors appear in type signatures: PodiumClient.connect returns Effect.Effect<PodiumConnection, PodiumConnectionError>. Callers know exactly what can go wrong and must handle it. If you add a new error type to a function, every caller that does not handle it becomes a compile-time error. With Promises, adding a new throw path is invisible — callers silently miss it until production. The outer catchAll in the message router maps every typed error to a safe client-facing response — no instanceof chains, no string-matching:
.pipe(
  Effect.catchAll((err) =>
    Effect.gen(function* () {
      const code = (err as { _tag?: string })._tag ?? "INTERNAL_ERROR"
      const safeMessages: Record<string, string> = {
        Unauthenticated: "Authentication required",
        SessionNotFound: "Session not found",
        PodiumConnectionError: "Failed to connect to agent",
        DbError: "Database operation failed",
      }
      return { kind: "respond", data: { type: "error", code, message: safeMessages[code] ?? sanitizeErrorMessage(err) } }
    })
  )
)

Streams for Event Processing

The Podium agent produces a stream of events (thinking progress, tool calls, text deltas, terminal output) over a WebSocket connection. Diminuendo models this as an Effect.Stream backed by a Queue:
const eventQueue = yield* Queue.unbounded<PodiumEvent>()
ws.addEventListener("message", (ev) => {
  Effect.runSync(Queue.offer(eventQueue, parseEvent(ev.data)))
})
ws.addEventListener("close", () => {
  Effect.runSync(Queue.shutdown(eventQueue))
})

const connection: PodiumConnection = {
  events: Stream.fromQueue(eventQueue),
}
The stream is consumed in a daemon fiber that outlives the originating request:
const eventFiber = yield* Effect.forkDaemon(
  connection.events.pipe(
    Stream.runForEach((event) =>
      Effect.gen(function* () {
        const clientEvents = mapPodiumEvent(sessionId, turnId, event)
        for (const ce of clientEvents) {
          yield* broadcaster.sessionEvent(sessionId, ce)
          yield* dispatchToHandler(ctx, ce, turnId, event)
        }
      })
    ),
    Effect.tap(() => transitionSessionState(tenantId, sessionId, cs, "inactive")),
    Effect.catchAll((err) => /* reconnect or emit turn_error */),
  )
)
The daemon fiber runs until the stream completes (agent disconnects), errors (network failure), or is explicitly interrupted (session deletion via Fiber.interrupt).

Refs for Mutable State

Effect’s Ref provides thread-safe mutable state with atomic read-modify-write operations. Each active session maintains a ConnectionState — a struct of 15+ Refs tracking the current turn, accumulated text, pending tool calls, thinking state, and billing reservation:
export interface ConnectionState {
  readonly turnId: Ref.Ref<string | null>
  readonly fullContent: Ref.Ref<string>
  readonly stopRequested: Ref.Ref<boolean>
  readonly pendingToolCalls: Ref.Ref<Map<string, { toolName: string; startedAt: number }>>
  readonly isThinking: Ref.Ref<boolean>
  readonly sessionState: Ref.Ref<SessionState>
  // ... 15 refs total
}
The active sessions map uses HashMap (an immutable persistent data structure) inside a Ref for concurrent-safe updates:
const activeSessionsRef = yield* Ref.make(HashMap.empty<string, ActiveSession>())
yield* Ref.update(activeSessionsRef, HashMap.set(sessionId, activeSession))

Schedule, Circuit Breaker, and Redacted

Retry policies compose with any effectful operation:
export const podiumRetry = Schedule.exponential("500 millis").pipe(
  Schedule.jittered,
  Schedule.compose(Schedule.recurs(3)),
)
yield* podium.createInstance(params).pipe(Effect.retry(podiumRetry))
The circuit breaker (src/resilience/CircuitBreaker.ts) prevents cascading failures by fast-failing requests when a backend is known to be down. It uses Ref for atomic state transitions (closed → open → half-open) with a 5-failure threshold and 30-second cooldown. Secrets are typed as Redacted<string> — an opaque wrapper that renders as <redacted> in logs, JSON.stringify, and Effect’s structured logger. You must call Redacted.value() to unwrap, making every secret consumption site auditable with a single grep.

Measurable Outcomes

Since adopting Effect TS for Diminuendo:
  • Zero uncaught exceptions in production. Every error is typed, handled, and mapped to a client-facing code before it leaves the message router.
  • Zero resource leaks. Structured concurrency guarantees every fiber, queue, and database connection is cleaned up on shutdown. The 10-second force-exit timer has never fired.
  • Zero hidden dependencies. Every service’s requirements are visible in its Layer type.
  • 120+ test files pass with no flaky failures. Tests assert on specific error types (Unauthenticated, SessionNotFound) rather than string-matching.

Feature Map

Effect FeatureWhere It Is UsedWhat It Prevents
Context.Tag + LayerEvery service definition (24 services, 1 composition file)Import-coupled singletons, untestable modules
Data.TaggedErrorsrc/errors.ts (14 error types)Untyped exceptions, string-matching error handling
Effect.genEvery handler, every service methodCallback nesting, promise chain readability
Stream + QueuePodiumClient event stream, SessionRuntimeBackpressure-ignorant event handling, memory leaks
Effect.forkDaemonEvent stream fibers, scheduler fibers, worker fibersOrphaned background tasks, shutdown leaks
Ref + HashMapConnectionState (15 refs), AutomationEngine, BroadcasterRace conditions in mutable state
DeferredTurn completion, automation run completionCallback-based completion signaling
Fiber.interruptSession deletion, shutdown, automation cleanupDangling background work after resource destruction
ScheduleRetry policies for Podium and EnsembleAd-hoc retry loops, missing jitter, thundering herds
Config + RedactedAppConfig (16 config values, 5 redacted)Accidental secret logging, unvalidated config access
Effect.raceScheduler sleep-or-wake patternPolling loops, missed wake signals
Effect’s programming model — generators, typed error channels, layer composition — is unfamiliar to most TypeScript developers. The first week is uncomfortable. The second week is productive. By the third week, you cannot imagine going back to try/catch.The investment is frontloaded: once you understand Effect.gen, Layer, Ref, and TaggedError, you have 90% of what you need. The remaining features (Stream, Queue, Fiber, Schedule, Deferred) are used in specific modules and can be learned as needed.Diminuendo’s codebase is itself a teaching resource. Read src/automation/AutomationEngine.ts to see Queue, Fiber, Ref, HashMap, Deferred, Duration, Either, and Effect.race working together in a single file.