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 is the comprehensive treatment: it begins with why Effect was chosen, proceeds through the patterns used in the gateway, and concludes with a reference for every Effect primitive you will encounter in the source.Why 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<Success, Error, Requirements>. The three type parameters make success values, failure modes, and dependencies explicit at the type level.
The Core Type
- Success — the value produced when the computation succeeds
- Error — the typed error(s) that may occur, tracked in the type signature
- Requirements — the services this computation depends on, resolved at composition time
Effect is a description of a computation, not the computation itself. This laziness is what enables composition, retry, timeout, cancellation, and resource management to work as orthogonal combinators rather than tangled ad-hoc patterns.
Patterns
Layers as Dependency Injection
Every service in Diminuendo is defined as aContext.Tag — a typed key that identifies a service interface — with a corresponding Live implementation wrapped in a Layer.
Defining a Service
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. If the dependency is not provided, the program does not compile.
Composing the Layer Graph
Insrc/main.ts, all layers are composed into a single dependency tree. The composition makes the dependency structure explicit and visible in one place:
Typed Errors
Diminuendo usesData.TaggedError to define a closed set of domain errors. Each error is a branded class with a _tag discriminant:
PodiumClient.connect returns Effect.Effect<PodiumConnection, PodiumConnectionError> — the caller knows exactly what can go wrong and must handle it explicitly.
Error Propagation Without Try/Catch
In theMessageRouter, errors propagate through Effect.gen without any try/catch blocks:
yield* can fail with a typed error. If any fails, execution short-circuits to the outer catchAll, which maps every typed error to a safe client-facing response:
The
sanitizeErrorMessage function strips internal details from error messages before they reach clients. This prevents information leakage while preserving useful error codes for debugging.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 EffectStream and consumes it in a forked daemon fiber.
Creating the Event Stream
PodiumClient.connect() creates an unbounded Queue, feeds incoming WebSocket messages into it, and exposes it as a Stream:
Consuming the Stream
MessageRouterLive consumes the stream with Stream.runForEach, forked as a daemon fiber so it outlives the originating request:
Refs for Mutable State
Effect’sRef provides thread-safe mutable state with atomic read-modify-write operations. Diminuendo uses Refs extensively for per-connection state that changes during a turn.
ConnectionState
Each active session maintains aConnectionState — a struct of Refs tracking the current turn, accumulated text, pending tool calls, thinking state, billing reservation, and more:
Ref.update guarantees atomic read-modify-write, which is essential when multiple events arrive concurrently on the stream fiber:
HashMap (an immutable persistent data structure) inside a Ref for concurrent-safe updates:
Effect.gen for Sequential Composition
Effect’s generator syntax provides readable sequential composition without callback nesting or promise chains. Eachyield* suspends execution until the effect completes, and failures short-circuit through the generator:
yield* fails, execution jumps to the outer catchAll. No intermediate try/catch is needed. The generator is the happy path; the error channel is the type system’s concern.
Schedule for Retry and Backoff
Effect’sSchedule combinator provides composable retry policies:
Why jitter matters
Why jitter matters
Without jitter, all retrying clients hit the backend simultaneously after each exponential delay, creating a thundering herd effect.
Schedule.jittered adds random variance to each delay, spreading retries over time and reducing peak load on the recovering service. This is especially important for Podium connections, where multiple sessions may disconnect simultaneously during a backend restart.Effect.retry:
Circuit Breaker
For persistent upstream failures, the gateway includes a circuit breaker that prevents cascading failures by fast-failing requests when a backend is known to be down:Ref for its internal state (closed/open/half-open, failure count, cooldown timer), ensuring atomic state transitions even when multiple fibers attempt concurrent requests. The three-state model — closed (allowing requests), open (fast-failing), half-open (probing recovery) — is the standard Hystrix pattern, implemented here without external dependencies.
Redacted for Secrets
Configuration values that contain secrets are typed asRedacted<string> — an opaque wrapper that prevents accidental logging or serialization:
Redacted value to console.log, JSON.stringify, or Effect’s structured logger, it renders as <redacted>. You must explicitly call Redacted.value() to unwrap it — making every secret consumption site auditable with a single grep:
Pattern Summary
| Pattern | What It Solves | Where It Appears |
|---|---|---|
Context.Tag + Layer | Dependency injection without singletons | Every service definition (20+ services, 1 composition file) |
Data.TaggedError | Typed errors in the type signature | src/errors.ts — 14 error types |
Stream + Queue | Backpressure-aware event streaming | PodiumClient.connect() event stream |
Effect.forkDaemon | Background fibers that outlive their parent | Event stream consumption, automation scheduler |
Ref + HashMap | Thread-safe mutable state | ConnectionState, activeSessionsRef, Broadcaster |
Effect.gen | Readable sequential composition | Every handler in the router |
Schedule | Composable retry policies | RetryPolicy.ts — exponential + jitter |
Redacted | Preventing accidental secret logging | AppConfig — API keys, client secrets |
Addressing the Objections
The learning curve is steep
The learning curve is steep
This is true. 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. Every pattern is used in a real context, not an abstract example. Read src/automation/AutomationEngine.ts to see Queue, Fiber, Ref, HashMap, Deferred, Duration, Either, and Effect.race working together in a single file.It makes the codebase harder to read
It makes the codebase harder to read
Effect.gen(function* () { ... }) is more verbose than async/await. This is the most common initial reaction.What it buys you:- Every
yield*is a suspension point where the type system tracks what can go wrong - Every service dependency is visible in the function signature, not hidden in module imports
- Every resource cleanup is guaranteed by the runtime, not by developer discipline
The bundle size is too large
The bundle size is too large
Effect adds approximately 200KB to the bundled output. For a backend service (which is Diminuendo’s primary deployment), this is negligible — startup time is unaffected, and the runtime is in-process.For client-side use, tree-shaking removes unused Effect modules. The web client’s
GatewayAdapter uses Effect types, and the impact is acceptable for a single-page application with a modern bundler.Hiring is harder with a niche library
Hiring is harder with a niche library
The pool of developers fluent in Effect TS is smaller than the general TypeScript pool. This is a real constraint.However: Effect is TypeScript. Any TypeScript developer can read and modify Effect code with a one-week ramp-up. The patterns (typed errors, dependency injection, resource management) are universal. Developers who know Scala’s ZIO, Haskell’s IO monad, or Rust’s
Result type will recognize the concepts immediately. And the alternative — hiring developers fluent in raw TypeScript who spend weeks debugging leaked resources, unhandled rejections, and hidden dependencies — is not cheaper.We could use other DI frameworks instead
We could use other DI frameworks instead
Inversify, tsyringe, and similar DI libraries solve dependency injection but not the other three problems (typed errors, resource management, structured concurrency). Effect solves all four in a single, cohesive abstraction.If you adopt a DI framework for injection, you still need a separate error handling strategy, a separate resource management strategy, and a separate concurrency strategy. Effect provides all of these as one system where the features compose with each other.
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. The
catchAllat the bottom of the route function is the single error boundary. - Zero resource leaks. Structured concurrency guarantees that every fiber, queue, and database connection is cleaned up on shutdown. The force-exit timer (10 seconds) has never fired.
- Zero hidden dependencies. Every service’s requirements are visible in its Layer type. Adding a new dependency to a service produces a compile-time error if the Layer graph does not provide it.
- 935+ tests pass with no flaky failures. The typed error system means tests assert on specific error types (
Unauthenticated,SessionNotFound) rather than string-matching error messages. No test has ever failed due to an unhandled rejection or a leaked timer.
Reference
The remainder of this page is a condensed reference for every Effect concept used in Diminuendo. Each entry gives the one-sentence explanation, a minimal code example, and a link to the official docs.Core Types
Effect<Success, Error, Requirements>
The fundamental type. Represents a lazy computation that may succeed withSuccess, fail with Error, or require services described by Requirements. Nothing runs until you explicitly execute it.
Effect.gen
Generator-based syntax for writing sequential Effect code.yield* unwraps an Effect (like await for Promises, but type-safe).
Effect.pipe
Left-to-right function composition. Chains transformations on an Effect without nested calls.Creating Effects
| Function | What it does | When to use it |
|---|---|---|
Effect.succeed(value) | Wraps a value in a successful Effect | Constants, already-computed values |
Effect.fail(error) | Creates a failed Effect | Domain errors, validation failures |
Effect.sync(() => ...) | Wraps a synchronous function | Side effects that cannot throw |
Effect.try(() => ...) | Wraps a sync function that might throw | JSON.parse, SQLite queries |
Effect.tryPromise(() => ...) | Wraps a Promise that might reject | fetch, file I/O |
Effect.async(resume => ...) | Bridges callback-based APIs | Event emitters, process signals |
Effect.sleep(duration) | Delay execution | Timeouts, backoff, throttling |
Running Effects
Effects are lazy — nothing happens until you run them. These are the escape hatches that bridge Effect world to the outside.| Function | What it does | When to use it |
|---|---|---|
Effect.runPromise(effect) | Execute and return a Promise | Application entry point |
Effect.runSync(effect) | Execute synchronously | Migrations, CLI tools |
Error Handling
Data.TaggedError
Type-safe error classes with a_tag discriminator. Unlike thrown exceptions, these are tracked in the Effect type signature — the compiler forces you to handle them.
Catching Errors
| Function | What it does |
|---|---|
Effect.catchAll(handler) | Catch all errors, must return a new Effect |
Effect.catchTag("Tag", handler) | Catch a specific tagged error by name |
Effect.matchEffect({ onSuccess, onFailure }) | Branch on success or failure |
Effect.orElseSucceed(fallback) | Recover from any error with a default value |
Effect.catchAllDefect(handler) | Catch unexpected panics (not typed errors) |
Dependency Injection
Context.Tag
A typed key that identifies a service interface. Think of it as a type-safe string key for a service registry.Layer
A recipe for building a service. Layers declare what they provide and what they require. Effect resolves the full dependency graph at composition time.Composing Layers
| Function | What it does |
|---|---|
Layer.provide(layer, dependency) | Wire a dependency into a layer |
Layer.merge(a, b) | Combine two layers side by side |
Layer.mergeAll(a, b, c, ...) | Combine many layers |
Concurrency Primitives
Ref<A>
A mutable reference that is safe for concurrent access. Every read and update is atomic.Fiber
A lightweight virtual thread. Like a Promise, but supports cancellation, supervision, and composition.Queue<A>
A bounded, async-safe work queue. Producersoffer items, consumers take them. Backpressure is built in.
Deferred<A>
A one-shot, write-once value. One fiber produces a result, another fiber waits for it. Like a typed, cancellation-awarePromise.resolve().
HashMap
An immutable, structurally-shared map. Unlike JavaScriptMap, updates return new instances — safe to use inside Ref.update().
Schema
Runtime type validation that generates both TypeScript types and JSON decoders from a single definition. Replaces Zod/Joi with compile-time and runtime safety.Defining Schemas
Validating Data
Discriminated Unions
The wire protocol uses discriminated unions for message types:Config and Secrets
Config
Type-safe environment variable loading with defaults, validation, and secret handling.Redacted<A>
A wrapper that prevents secrets from being accidentally logged or serialized.Scheduling and Retries
Composable retry and repetition policies. Combine strategies withpipe.
Logging
Built-in structured logging that flows through the Effect context. No global logger imports needed.Trace < Debug < Info < Warning < Error < Fatal
Configure via Layer:
Control Flow
Iterating
Combining
| Function | What it does |
|---|---|
Effect.all([a, b, c]) | Run effects, collect all results |
Effect.all([a, b, c], { concurrency: 3 }) | Same, but in parallel |
Effect.race(a, b) | First to complete wins |
Effect.zip(a, b) | Run sequentially, return tuple |
Effect.zipRight(a, b) | Run both, keep second result |
Effect.zipLeft(a, b) | Run both, keep first result |
Transforming
| Function | What it does |
|---|---|
Effect.map(effect, fn) | Transform the success value |
Effect.flatMap(effect, fn) | Chain into another Effect |
Effect.tap(effect, fn) | Side-effect without changing the value |
Effect.as(effect, value) | Replace the success value |
Quick Comparison
For developers coming from other ecosystems:| Concept | Promise / async-await | Effect |
|---|---|---|
| Async computation | Promise<T> | Effect<T, E, R> |
| Error handling | try/catch | Type-tracked error channel |
| Dependencies | Module imports / DI container | Context.Tag + Layer |
| Mutable state | let x = ... | Ref<T> |
| Work queue | Hand-rolled with arrays | Queue<T> |
| Background task | setTimeout / detached promise | Fiber via Effect.fork |
| One-shot signal | new Promise(resolve => ...) | Deferred<T> |
| Type validation | Zod / Joi | Schema |
| Retry logic | Manual loop | Schedule |
| Env config | process.env.PORT | Config.number("PORT") |
| Secrets | Raw strings | Redacted<string> |
Feature Map
Every Effect feature used in Diminuendo maps to a specific operational benefit:| Effect Feature | Where It Is Used | What It Prevents |
|---|---|---|
Context.Tag + Layer | Every service definition (20+ services, 1 composition file) | Import-coupled singletons, untestable modules |
Data.TaggedError | src/errors.ts (14 error types) | Untyped exceptions, string-matching error handling |
Effect.gen | Every handler, every service method | Callback nesting, promise chain readability |
Stream + Queue | PodiumClient event stream, SessionRuntime event consumption | Backpressure-ignorant event handling, memory leaks |
Effect.forkDaemon | Event stream fibers, scheduler fibers, worker fibers | Orphaned background tasks, shutdown leaks |
Ref + HashMap | ConnectionState (15 refs), AutomationEngine scheduler registry, Broadcaster topic tracking | Race conditions in mutable state |
Deferred | Turn completion in SessionRuntime, automation run completion | Callback-based completion signaling |
Queue | Automation scheduler wake signal, work queue | Unbounded task accumulation, missing backpressure |
Fiber.interrupt | Session deletion, shutdown, automation cleanup | Dangling background work after resource destruction |
Schedule | Retry policies for Podium and Ensemble | Ad-hoc retry loops, missing jitter, thundering herds |
Duration | Automation timeouts, backoff computation | String-based duration parsing, unit confusion |
Config + Redacted | AppConfig (16 config values, 5 redacted) | Accidental secret logging, unvalidated config access |
Effect.race | Scheduler sleep-or-wake pattern | Polling loops, missed wake signals |
Either | Error handling in AutomationEngine execution paths | Unhandled rejection, missing error branches |
Cron | Automation schedule computation | Third-party cron libraries, parse-time vs runtime errors |
Schema | Protocol validation (21 client message types) | Runtime type errors, unchecked JSON shapes |
Getting Started with Effect
If you are convinced and want to adopt Effect in your own service:Read the official documentation
effect.website has comprehensive guides. Start with the “Getting Started” and “Error Management” sections.
Study Diminuendo's patterns
Read
src/config/AppConfig.ts for a minimal Layer example. Read src/errors.ts for TaggedError definitions. Read src/automation/AutomationEngine.ts for a complex example using most Effect features together.Start with TaggedError + Effect.gen
The highest-value, lowest-effort starting point is replacing thrown exceptions with
TaggedError classes and replacing async/await with Effect.gen. This gives you typed errors without adopting the full Layer system.