| name | effect-ts |
| description | This skill should be used when the user works with Effect (Effect-TS) — writing or reviewing Effect.Service definitions, Layer composition (Layer.provide/provideMerge/mergeAll), Effect.scoped resource handling, converting imperative try/catch/async code to the Effect error channel, deriving types from Schema (Schema-as-SSOT), or testing Effect code with @effect/vitest, TestClock, and Layer-based mocks. Provides generalized design principles and minimal reference patterns for Effect 3.x. |
| version | 2.0.0 |
Provide generalized design principles and minimal reference patterns for building
applications with Effect (Effect-TS): unifying service definitions, disciplined Layer
composition, escaping the Effect runtime as late as possible, converting imperative code
to the Effect error channel, deriving types from Schema, and testing with @effect/vitest.
The focus is on the "why" of each rule (how dependency leaks arise, why hot-loop escapes
hurt) followed by the smallest code that demonstrates the fix.
Effect (Effect-TS) design principles and reference patterns: Effect.Service definitions, Layer composition, scoped resources, Schema-as-SSOT, error-channel conversion, and @effect/vitest testing
Base TypeScript compiler configuration, generics, utility types, module resolution, and general tsconfig/tooling. This skill covers only the Effect-specific surface built on top of TypeScript.
Effect.Service and layer wiring (Layer.provide/provideMerge/mergeAll), Effect.scoped resource lifecycles, escaping the Effect runtime late, converting imperative try/catch/async to the typed error channel, deriving types from Schema, and it.effect/it.scoped/TestClock test patterns.
<version_info>
<current_version>Effect 3.x (verified against effect 3.19.x)</current_version>
@effect/vitest 0.25.x — Effect-native test bindings (it.effect / it.scoped / it.live)
vitest 3.2.x — underlying runner (expect still imported from vitest)
<api_stability>
Effect.Service, Layer.provide/provideMerge/merge/mergeAll, Effect.scoped, TestClock, and Schema.Struct are stable across the Effect 3.x line. Patterns below were confirmed against the official Effect documentation.
Import everything from the single "effect" package (import { Effect, Layer, Schema, Ref } from "effect"); test bindings come from "@effect/vitest".
</api_stability>
Effect 2.x and pre-3.x tutorials predate Effect.Service and the current Schema module location (Schema moved into the core "effect" package). Do not mix guidance across major versions.
</version_info>
Read - Inspect service, layer, schema, and test files
Edit - Apply targeted conversions and refactors
Bash - Run type checks and the vitest suite
mcp__plugin_claude-code-home-manager_context7__query-docs - Verify current Effect / @effect/vitest APIs before asserting version-specific behavior
A service is an interface of capabilities addressed by a tag; a Layer builds the implementation and declares what it needs to be built.
Every Effect carries a third type parameter R (`Effect<A, E, R>`) listing unmet requirements. A program is only runnable when R is `never`; Layers are what discharge R.
The Effect runtime should be entered once at the program edge. `runPromise`/`runSync` scattered through hot paths discard the error channel, interruption, and scheduling that Effect exists to provide.
A Schema is the single source of truth: the static type is derived from it, so validation and typing never drift apart.
<service_definition>
Prefer one uniform way to declare services. Effect.Service is syntactic sugar over a
tag plus a default Layer: it generates the tag from the identifier string, builds the
implementation from a constructor (effect / scoped / sync / succeed), and exposes a
.Default Layer. Reserve the manual Context.GenericTag + Layer.effect form for cases
where a default implementation must not be assumed.
Application-level services that have a sensible runtime implementation
import { Effect } from "effect"
// The class value doubles as the tag; the string is the stable identifier.
class Database extends Effect.Service<Database>()("app/Database", {
// `effect` runs when the layer is built and returns the implementation.
effect: Effect.gen(function* () {
const config = yield* Config
return {
query: (sql: string) => Effect.succeed(`result of ${sql} @ ${config.url}`),
} as const
}),
// Dependencies needed to BUILD this service.
dependencies: [Config.Default],
}) {}
// Generated layers:
// Database.Default -> Layer<Database> (deps baked in)
// Database.DefaultWithoutDependencies -> Layer<Database, never, Config> (deps external)
</example>
<notes>
<item>Consumers write `const db = yield* Database` regardless of how the service was defined — migrating from Context.GenericTag to Effect.Service does not change call sites.</item>
<item>Use a stable, namespaced identifier string (e.g. "app/Database"). It participates in tag identity.</item>
</notes>
Services that acquire resources (connections, listeners, timers) needing release
class EventBus extends Effect.Service<EventBus>()("app/EventBus", {
// `scoped` ties acquisition to a Scope; release runs on scope close.
scoped: Effect.gen(function* () {
const handler = yield* makeHandler
yield* Effect.acquireRelease(
Effect.sync(() => source.subscribe(handler)),
() => Effect.sync(() => source.unsubscribe(handler)),
)
return { publish: (e: Event) => Effect.sync(() => source.emit(e)) } as const
}),
}) {}
`Effect.acquireRelease` inside a `scoped` constructor guarantees cleanup symmetric with construction — the general replacement for manual add/removeEventListener or setInterval/clearInterval pairs.
Library-facing or inherently contextual services where the caller must supply the implementation
import { Effect, Context, Layer } from "effect"
class Clock extends Context.Tag("app/Clock")<
Clock,
{ readonly now: Effect.Effect<number> }
>() {}
const ClockLive = Layer.effect(
Clock,
Effect.sync(() => ({ now: Effect.sync(() => performance.now()) })),
)
</example>
<notes>
<item>Context.Tag makes the default implementation optional; Effect.Service requires one. Choose Context.Tag only when "no assumed implementation" is the point.</item>
</notes>
<decision_tree name="service_style">
Does this service have one sensible runtime implementation owned by the app?
<if_yes>Use Effect.Service with effect/scoped constructor and dependencies.</if_yes>
<if_no>Use Context.Tag + a separately provided Layer so callers choose the implementation.</if_no>
</decision_tree>
</service_definition>
<layer_composition>
Layers form a dependency graph. The discipline that avoids type errors and leaked
requirements is: sequence dependent layers with provide / provideMerge, and reserve flat
merge for genuinely independent layers.
`inner.pipe(Layer.provide(outer))` builds `inner` using `outer`, and CONSUMES `outer` — it does not appear in the resulting output type. Use when the dependency is an implementation detail the rest of the app should not see.
// Layer<Database, never, never> (Config is satisfied and hidden)
const DatabaseWired = Database.DefaultWithoutDependencies.pipe(
Layer.provide(Config.Default),
)
Like provide, but ALSO keeps the provided service in the output. Use when a lower-level service must remain visible to the final program in addition to satisfying a higher one.
// Layer<Database | Config> (Config both feeds Database and stays available)
const Wired = Database.DefaultWithoutDependencies.pipe(
Layer.provideMerge(Config.Default),
)
Combine layers side by side. The output is the union of outputs AND the union of requirements — no wiring happens. `mergeAll(a, b, c)` is the n-ary form.
// Layer<Metrics | Tracer, never, ...both requirements unioned...>
const Observability = Layer.merge(Metrics.Default, Tracer.Default)
When layer B depends on layer A, `Layer.mergeAll(A, B)` does NOT feed A into B. It
merely places both in one layer whose requirement set is the union, so A's service
leaks upward as an unsatisfied requirement of the combined layer. The leak surfaces
later — typically as an assignability error at the top-level `Effect.provide`, far from
the merge that caused it. Sequencing with provide/provideMerge discharges the
dependency at the point of composition, where the type is still local and legible.
If B needs A, wire them (`B.pipe(Layer.provide(A))`). Use merge/mergeAll only for layers with no dependency relationship.
Keep `Effect.scoped` OUTSIDE the layer-provision pipeline and apply `Effect.provide`
before scoping. Wrapping a partially-provided program in `Effect.scoped` too early can
narrow the inferred requirement environment and produce spurious `Effect<..., R>`
assignability errors even though every requirement is eventually satisfied.
// Prefer: provide first, scope the fully-provided program last.
const runnable = program.pipe(Effect.provide(MainLayer), Effect.scoped)
<decision_tree name="composition_operator">
Does one layer depend on another?
Layer.provide
Layer.provideMerge
Layer.merge / Layer.mergeAll
</decision_tree>
</layer_composition>
<escape_effect_late>
"Escape the Effect runtime as late as possible." Each Effect.runPromise / Effect.runSync
crosses the boundary out of Effect: it forfeits typed errors, interruption, structured
concurrency, and scheduling. Entering that boundary once per hot iteration (render frame,
stream item, request) multiplies the cost and fragments error handling into many
independent .catch() sites.
<anti_pattern name="runtime_escape_in_hot_loop">
A per-iteration callback fires many independent Effect.runPromise calls, each with its own ad-hoc .catch.
// BAD: N runtime entries per tick, N separate error paths, no interruption.
const tick = () => {
Effect.runPromise(stepA()).catch(console.error)
Effect.runPromise(stepB()).catch(console.error)
Effect.runPromise(stepC()).catch(console.error)
scheduleNext(tick)
}
</anti_pattern>
Bridge the external callback into Effect ONCE by enqueuing, then process on a single forked fiber that stays inside Effect.
import { Effect, Queue } from "effect"
const makeLoop = Effect.gen(function* () {
const commands = yield* Queue.unbounded<number>()
// Single processing fiber: one runtime, unified error channel.
yield* Effect.forever(
Effect.gen(function* () {
const ts = yield* Queue.take(commands)
yield* stepA(ts)
yield* stepB(ts)
yield* stepC(ts)
}),
).pipe(Effect.forkScoped)
// The ONLY escape: hand each external event to the queue.
const onTick = (ts: number) => Effect.runFork(Queue.offer(commands, ts))
return { onTick } as const
})
</example>
<notes>
<item>Prefer `Effect.runFork` over `Effect.runPromise` at the boundary when you are firing-and-forgetting; it returns a Fiber you can interrupt and does not create an unhandled-rejection surface.</item>
<item>Attach `Effect.catchAllCause(cause => Effect.logError(...))` to the forked program so failures land in Effect's logger instead of a bare `.catch(console.error)`.</item>
<item>One processing fiber gives a single pause/resume/interrupt control point that the multi-escape version cannot.</item>
</notes>
<imperative_to_effect>
Correspondence table for replacing imperative constructs with Effect equivalents that preserve the typed error channel and interruption.
An async function passed to Effect.tryPromise is already correctly wrapped — rewriting its internal await chain into Promise combinators is a style change, not a correctness fix; leave it unless there is a reason.
Not every callback must be lifted. Event-emitter integrations with third-party libraries whose API is fundamentally callback-based are legitimate boundaries; wrap the surface in an Effect interface rather than abstracting the whole library.
Local loop counters and accumulators inside a single function are fine as plain let. Reserve Ref for state that is shared across effects or must survive between fiber steps.
</imperative_to_effect>
<schema_as_ssot>
Define data with Schema and DERIVE the static type from it, so a value type and its
validator can never diverge. This is the default for domain/application value types.
import { Schema } from "effect"
export const PositionSchema = Schema.Struct({
x: Schema.Number,
y: Schema.Number,
z: Schema.Number,
})
export type Position = Schema.Schema.Type<typeof PositionSchema>
// Branded identifiers via Schema (nominal typing over primitives):
export const EntityIdSchema = Schema.String.pipe(Schema.brand("EntityId"))
export type EntityId = Schema.Schema.Type<typeof EntityIdSchema>
// Tagged unions:
export const CommandSchema = Schema.TaggedStruct("Tick", { at: Schema.Number })
</example>
<notes>
<item>Universal pattern: `export const XSchema = Schema.Struct({...})` then `export type X = Schema.Schema.Type<typeof XSchema>`. Never hand-write a parallel interface.</item>
<item>Decode at boundaries with `Schema.decodeUnknown` (Effect-returning) or `decodeUnknownSync`; keep structural validation in the schema and push value-range clamping into an explicit follow-up step when bounds are business rules rather than shape.</item>
<item>For JS `Date` instances use `Schema.DateFromSelf`; for ISO-string dates use `Schema.Date`. Choosing the wrong one silently changes the decoded representation.</item>
</notes>
Types that must NOT be forced into Schema — converting them costs more than it returns or is impossible.
TypedArrays (Uint8Array, Float32Array, …) and structs that live on a per-iteration hot path — Schema decode overhead is unacceptable there.
Instances of third-party classes (physics bodies, GPU handles, DB driver objects, browser objects like IndexedDB) — they are not plain value types.
Structures whose identity is a mutable Map/Set/LRU cache with a dirty flag — Schema models values, not mutable containers.
Service capability interfaces (the object of methods a Layer produces) — these are behavior, not data.
Pure functions do not need Effect wrapping or Schema; keep them plain.
Test Effect code with @effect/vitest, composing dependencies as Layers and injecting
mocks as Layers. Prefer the Effect-native `it.effect` family so the test body is itself
an Effect and the test environment (including a controllable clock) is provided
automatically.
import { describe, it } from "@effect/vitest"
import { expect } from "vitest"
import { Effect } from "effect"
describe("Database", () => {
// it.effect: body returns an Effect; TestContext (incl. TestClock) auto-provided.
it.effect("returns a result", () =>
Effect.gen(function* () {
const db = yield* Database
const out = yield* db.query("SELECT 1")
expect(out).toContain("SELECT 1")
}).pipe(Effect.provide(TestLayer)),
)
})
</example>
<notes>
<item>`describe`/`it` come from "@effect/vitest"; `expect` still comes from "vitest".</item>
<item>`it.effect` provides the Test services (TestClock, TestRandom, …) automatically. Use `it.scoped` when the body opens a Scope, and `it.live` when you deliberately want the real clock/services.</item>
</notes>
Assemble the unit-under-test with its real dependency layers once, reuse across cases.
const TestLayer = Database.DefaultWithoutDependencies.pipe(
Layer.provide(Config.Default),
)
Replace a dependency with a fixed implementation using Layer.succeed keyed by its tag.
const NoiseMock = Layer.succeed(NoiseSource, {
sample: (_x: number) => 0.5,
reseed: (_seed: number) => Effect.void,
})
const program = subjectUnderTest.pipe(Effect.provide(
Subject.DefaultWithoutDependencies.pipe(Layer.provide(NoiseMock)),
))
</example>
<notes>
<item>For an Effect.Service you can alternatively construct a mock instance (`new Subject({ ...methods })`) and inject it with `Effect.provideService`.</item>
<item>Keep mocks synchronous where possible (`Effect.sync`, `Effect.void`, `Effect.succeed`) so tests stay deterministic.</item>
</notes>
Drive throttles, schedules, and delays with TestClock instead of real waiting.
import { Effect, TestClock } from "effect"
it.effect("fires after the interval", () =>
Effect.gen(function* () {
const before = yield* pollOutput
yield* TestClock.adjust("60 minutes") // advance virtual time
const after = yield* pollOutput
expect(before).not.toEqual(after)
}),
)
</example>
<notes>
<item>`TestClock.adjust` advances the virtual clock and runs any effects scheduled within that window — recurring/delayed effects become synchronous and deterministic.</item>
<item>To flush forked handlers inside a test, prefer an Effect-based pause such as `Effect.yieldNow()` over a raw `Promise` sleep; wall-clock sleeps are both flaky and, in strict Effect codebases, flagged as domain-layer violations.</item>
</notes>
<best_practices>
Sequence dependent layers with provide/provideMerge; never rely on mergeAll to wire a dependency.
Enter the Effect runtime once at the program edge; do not scatter runPromise/runSync through hot paths.
Standardize on Effect.Service for app services with a real implementation; reserve Context.Tag for "no assumed implementation".
Derive every value type from its Schema; do not hand-maintain a parallel interface.
Model failures as typed errors (Effect.fail / Data.TaggedError), not thrown exceptions or silent catch blocks.
Apply Effect.provide before Effect.scoped; keep scoping at the outermost layer of the pipeline.
Use Effect.acquireRelease inside scoped layers for every acquire/release pair (listeners, intervals, connections).
Test with it.effect and Layer-based mocks; use TestClock for anything time-dependent.
Route failures to Effect.logError via catchAllCause rather than bare .catch(console.error).
</best_practices>
<anti_patterns>
Using Layer.mergeAll to combine layers that depend on one another.
Wire with Layer.provide/provideMerge so the dependency is discharged at the composition site, not leaked to the top-level provide.
Calling Effect.runPromise/runSync inside a render frame, stream item, or request handler.
Bridge the external event into a Queue once and process on a single forked fiber.
Raw try/catch whose catch block returns a default and discards the cause.
Effect.try with an explicit error constructor, so the failure is typed and observable.
Declaring both a Schema and a separate interface for the same data.
Keep the Schema as SSOT and derive the type with Schema.Schema.Type.
Forcing TypedArrays, external class instances, or mutable caches into Schema.
Leave them as native types; Schema is for plain value data only.
Wrapping a partially-provided program in Effect.scoped before provide.
Provide first, then scope the fully-provided program.
</anti_patterns>
Wire dependent layers with provide/provideMerge; use merge/mergeAll only for independent layers.
Cross the Effect runtime boundary once, at the edge; keep hot paths inside Effect.
Import from the single "effect" package (and "@effect/vitest" for tests); do not mix pre-3.x module locations.
Verify any version-specific API claim against current Effect docs before asserting it.
Use Effect.Service for app services with a default implementation; Context.Tag otherwise.
Derive types from Schema; exclude hot-path/opaque/mutable types from Schema.
Replace imperative try/catch/setInterval/addEventListener with their Effect equivalents.
Test with it.effect + Layer mocks and drive time with TestClock.
Understand the service graph and where the Effect boundary sits
Identify services, their tags, and dependencies
Read / Grep for Effect.Service and Context.Tag declarations
Service dependency map
Locate every runPromise/runSync/runFork call and classify edge vs hot-path
Grep
Runtime-boundary inventory
Apply the targeted conversion
Unify service definitions and wire layers with provide/provideMerge
Edit
Composed layer graph without leaked requirements
Collapse hot-path escapes into a queue + single fiber; convert imperative constructs
Edit
Effect-native control flow
Confirm types and behavior
Run the type checker; a leaked requirement shows as an assignability error at the top-level provide
Bash (tsc --noEmit)
Zero requirement leaks
Run the vitest suite; add it.effect + TestClock coverage for converted time/schedule logic
Bash (vitest)
Green suite with deterministic time tests
<error_escalation inherits="core-patterns#error_escalation">
Style-only difference (async closure inside a correctly-wrapped Effect.tryPromise)
Requirement leak from mergeAll surfacing as a top-level provide type error
Hot-path runtime escapes dropping the error channel and interruption
Silent catch blocks hiding failures with no logging or typed error
</error_escalation>
Wire dependent layers explicitly; keep Effect.scoped outermost
Model errors in the typed channel, not via throw/silent catch
Derive value types from Schema and honor the Schema exclusion rules
runPromise/runSync in hot paths
Layer.mergeAll for dependent layers
Parallel Schema + interface declarations
<related_skills>
Underlying TypeScript config, generics, and utility types Effect builds on
General test strategy that @effect/vitest patterns plug into
Verify current Effect / @effect/vitest APIs and version behavior
Evidence-based tracing of requirement leaks and runtime-boundary issues
</related_skills>
<related_agents>
Locate service, layer, and schema declarations across the codebase
Review Effect composition and error-channel discipline
Design it.effect + TestClock coverage for converted logic
</related_agents>