
Effect Ts
- 8 installs
- 5 repo stars
- Updated August 4, 2026
- paulrberg/dot-agents
Helps with ai & agent building tasks.
About
effect-ts is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- effect-ts
- AI & Agent Building
- AI-coding skill
Effect Ts by the numbers
- 8 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #12,339 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/paulrberg/dot-agents --skill effect-tsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 4, 2026 |
| Repository | paulrberg/dot-agents ↗ |
What it does
Helps with ai & agent building tasks.
Files
Effect-TS Expert
Expert guidance for functional programming with the Effect library, covering error handling, dependency injection, composability, and testing patterns.
Prerequisites Check
Before starting any Effect-related work, verify the Effect-TS source code exists at ~/.effect.
If missing, stop immediately and inform the user. Clone it before proceeding:
git clone https://github.com/Effect-TS/effect.git ~/.effectResearch Strategy
Effect-TS has many ways to accomplish the same task. Proactively research best practices using the Task tool to spawn research agents when working with Effect patterns, especially for moderate to high complexity tasks.
Research Sources (Priority Order)
1. Codebase Patterns First — Examine similar patterns in the current project before implementing. If Effect patterns exist in the codebase, follow them for consistency. If no patterns exist, skip this step.
2. Effect Source Code — For complex type errors, unclear behavior, or implementation details, examine the Effect source at ~/.effect/packages/effect/src/. This contains the core Effect logic and modules.
When to Research
HIGH Priority (Always Research):
- Implementing Services, Layers, or complex dependency injection
- Error handling with multiple error types or complex error hierarchies
- Stream-based operations and reactive patterns
- Resource management with scoped effects and cleanup
- Concurrent/parallel operations and performance-critical code
- Testing patterns, especially unfamiliar test scenarios
MEDIUM Priority (Research if Complex):
- Refactoring imperative code (try-catch, promises) to Effect patterns
- Adding new service dependencies or restructuring service layers
- Custom error types or extending existing error hierarchies
- Integrations with external systems (databases, APIs, third-party services)
Research Approach
- Spawn multiple concurrent Task agents when investigating multiple related patterns
- Focus on finding canonical, readable, and maintainable solutions rather than clever optimizations
- Verify suggested approaches against existing codebase patterns for consistency (if patterns exist)
- When multiple approaches are possible, research to find the most idiomatic Effect-TS solution
Codebase Pattern Discovery
When working in a project that uses Effect, check for existing patterns before implementing new code:
1. Search for Effect imports — Look for files importing from 'effect' to understand existing usage 2. Identify service patterns — Find how Services and Layers are structured in the project 3. Note error handling conventions — Check how errors are defined and propagated 4. Examine test patterns — Look at how Effect code is tested in the project
If no Effect patterns exist in the codebase, proceed using canonical patterns from the Effect source and examples. Do not block on missing codebase patterns.
Effect Principles
Apply these core principles when writing Effect code:
Error Handling
- Use Effect's typed error system instead of throwing exceptions
- Define descriptive error types with proper error propagation
- Use
Effect.fail,Effect.catchTag,Effect.catchAllfor error control flow - See
./references/critical-rules.mdfor forbidden patterns
Dependency Injection
- Implement dependency injection using Services and Layers
- Define services with
Context.Tag - Compose layers with
Layer.merge,Layer.provide - Use
Effect.provideto inject dependencies
Composability
- Leverage Effect's composability for complex operations
- Use appropriate constructors:
Effect.succeed,Effect.fail,Effect.tryPromise,Effect.try - Apply proper resource management with scoped effects
- Chain operations with
Effect.flatMap,Effect.map,Effect.tap
Code Quality
- Write type-safe code that leverages Effect's type system
- Use
Effect.genfor readable sequential code - Implement proper testing patterns using Effect's testing utilities
- Prefer
Effect.fn()for automatic telemetry and better stack traces
Critical Rules
Read and internalize ./references/critical-rules.md before writing any Effect code. Key guidelines:
- INEFFECTIVE: try-catch in Effect.gen (Effect failures aren't thrown)
- AVOID: Type assertions (as never/any/unknown)
- RECOMMENDED:
return yield*pattern for errors (makes termination explicit)
Common Failure Modes
Quick links to patterns that frequently cause issues:
- SubscriptionRef version mismatch —
unsafeMake is not a function→ Quick Reference - Cancellation vs Failure — Interrupts aren't errors → Error Taxonomy
- Option vs null — Use Option internally, null at boundaries → option-null.md
- Stream backpressure — Infinite streams hang → streams.md
Explaining Solutions
When providing solutions, explain the Effect-TS concepts being used and why they're appropriate for the specific use case. If encountering patterns not covered in the documentation, suggest improvements while maintaining consistency with existing codebase patterns (when they exist).
Quick Reference
Creating Effects
Effect.succeed(value) // Wrap success value
Effect.fail(error) // Create failed effect
Effect.tryPromise(fn) // Wrap promise-returning function
Effect.try(fn) // Wrap synchronous throwing function
Effect.sync(fn) // Wrap synchronous non-throwing functionComposing Effects
Effect.flatMap(effect, fn) // Chain effects
Effect.map(effect, fn) // Transform success value
Effect.tap(effect, fn) // Side effect without changing value
Effect.all([...effects]) // Run effects (concurrency configurable)
Effect.forEach(items, fn) // Map over items with effects
// Collect ALL errors (not just first)
Effect.all([e1, e2, e3], { mode: "validate" }) // Returns all failures
// Partial success handling
Effect.partition([e1, e2, e3]) // Returns [failures, successes]Error Handling
// Define typed errors with Data.TaggedError (preferred)
class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{
userId: string
}> {}
// Direct yield of errors (no Effect.fail wrapper needed)
Effect.gen(function* () {
if (!user) {
return yield* new UserNotFoundError({ userId })
}
})
Effect.catchTag(effect, tag, fn) // Handle specific error tag
Effect.catchAll(effect, fn) // Handle all errors
Effect.result(effect) // Convert to Exit value
Effect.orElse(effect, alt) // Fallback effectError Taxonomy
Categorize errors for appropriate handling:
| Category | Examples | Handling |
|---|---|---|
| Expected Rejections | User cancel, deny | Graceful exit, no retry |
| Domain Errors | Validation, business rules | Show to user, don't retry |
| Defects | Bugs, assertions | Log + alert, investigate |
| Interruptions | Fiber cancel, timeout | Cleanup, may retry |
| Unknown/Foreign | Thrown exceptions | Normalize at boundary |
// Pattern: Normalize unknown errors at boundary
const safeBoundary = Effect.catchAllDefect(effect, (defect) =>
Effect.fail(new UnknownError({ cause: defect }))
)
// Pattern: Catch user-initiated cancellations separately
Effect.catchTag(effect, "UserCancelledError", () => Effect.succeed(null))
// Pattern: Handle interruptions differently from failures
Effect.onInterrupt(effect, () => Effect.log("Operation cancelled"))Pattern Matching (Match Module)
Default branching tool for tagged unions and complex conditionals.
import { Match } from "effect"
// Type-safe exhaustive matching on tagged errors
const handleError = Match.type<AppError>().pipe(
Match.tag("UserCancelledError", () => null), // Expected rejection
Match.tag("ValidationError", (e) => e.message), // Domain error
Match.tag("NetworkError", () => "Connection failed"), // Retryable
Match.exhaustive // Compile error if case missing
)
// Replace nested catchTag chains
// BEFORE: effect.pipe(catchTag("A", ...), catchTag("B", ...), catchTag("C", ...))
// AFTER:
Effect.catchAll(effect, (error) =>
Match.value(error).pipe(
Match.tag("A", handleA),
Match.tag("B", handleB),
Match.tag("C", handleC),
Match.exhaustive
)
)
// Match on values (cleaner than if/else)
const describe = Match.value(status).pipe(
Match.when("pending", () => "Loading..."),
Match.when("success", () => "Done!"),
Match.orElse(() => "Unknown")
)Services and Layers
// Pattern 1: Context.Tag (implementation provided separately via Layer)
class MyService extends Context.Tag("MyService")<MyService, { ... }>() {}
const MyServiceLive = Layer.succeed(MyService, { ... })
Effect.provide(effect, MyServiceLive)
// Pattern 2: Effect.Service (default implementation bundled)
class UserRepo extends Effect.Service<UserRepo>()("UserRepo", {
effect: Effect.gen(function* () {
const db = yield* Database
return { findAll: db.query("SELECT * FROM users") }
}),
dependencies: [Database.Default], // Optional service dependencies
accessors: true // Auto-generate method accessors
}) {}
Effect.provide(effect, UserRepo.Default) // .Default layer auto-generated
// Use UserRepo.DefaultWithoutDependencies when deps provided separately
// Effect.Service with parameters (3.16.0+)
class ConfiguredApi extends Effect.Service<ConfiguredApi>()("ConfiguredApi", {
effect: (config: { baseUrl: string }) =>
Effect.succeed({ fetch: (path: string) => `${config.baseUrl}/${path}` })
}) {}
// Pattern 3: Context.Reference (defaultable tags - 3.11.0+)
class SpecialNumber extends Context.Reference<SpecialNumber>()(
"SpecialNumber",
{ defaultValue: () => 2048 }
) {}
// No Layer required if default value suffices
// Pattern 4: Context.ReadonlyTag (covariant - 3.18.0+)
// Use for functions that consume services without modifying the type
function effectHandler<I, A, E, R>(service: Context.ReadonlyTag<I, Effect.Effect<A, E, R>>) {
// Handler can use service in a covariant position
}Generator Pattern
Effect.gen(function* () {
const a = yield* effectA;
const b = yield* effectB;
if (error) {
return yield* Effect.fail(new MyError());
}
return result;
});
// Effect.fn - automatic tracing and telemetry (preferred for named functions)
const fetchUser = Effect.fn("fetchUser")(function* (id: string) {
const db = yield* Database
return yield* db.query(id)
})
// Creates spans, captures call sites, provides better stack tracesResource Management
Effect.acquireUseRelease(acquire, use, release) // Bracket pattern
Effect.scoped(effect) // Scope lifetime to effect
Effect.addFinalizer(cleanup) // Register cleanup actionDuration
Effect accepts human-readable duration strings anywhere a DurationInput is expected:
// String syntax (preferred) - singular or plural forms work
Duration.toMillis("5 minutes") // 300000
Duration.toMillis("1 minute") // 60000
Duration.toMillis("30 seconds") // 30000
Duration.toMillis("100 millis") // 100
// Verbose syntax (avoid)
Duration.toMillis(Duration.minutes(5)) // Same result, more verbose
// Common units: millis, seconds, minutes, hours, days, weeks
// Also: nanos, microsScheduling
Effect.retry(effect, Schedule.exponential("100 millis")) // Retry with backoff
Effect.repeat(effect, Schedule.fixed("1 second")) // Repeat on schedule
Schedule.compose(s1, s2) // Combine schedulesState Management
Ref.make(initialValue) // Mutable reference
Ref.get(ref) // Read value
Ref.set(ref, value) // Write value
Deferred.make<E, A>() // One-time async valueSubscriptionRef (Reactive References)
// WARNING: Never use unsafeMake - it may not exist in your Effect version.
// If you see "unsafeMake is not a function", use the safe API below.
SubscriptionRef.make(initial) // Create reactive reference (safe)
SubscriptionRef.get(ref) // Read current value
SubscriptionRef.set(ref, value) // Update value (notifies subscribers)
SubscriptionRef.changes(ref) // Stream of value changes
// React integration (effect-atom pattern)
const ref = yield* SubscriptionRef.make<User | null>(null)
// Hook reads: useSubscriptionRef(ref) — returns current value or null
// Handle null explicitly in componentsConcurrency
Effect.fork(effect) // Run in background fiber
Fiber.join(fiber) // Wait for fiber result
Effect.race(effect1, effect2) // First to complete wins
Effect.all([...effects], { concurrency: "unbounded" })Configuration & Environment Variables
import { Config, ConfigProvider, Effect, Layer, Redacted } from "effect"
// Basic config values
const port = Config.number("PORT") // Required number
const host = Config.string("HOST").pipe( // Optional with default
Config.withDefault("localhost")
)
// Sensitive values (masked in logs)
const apiKey = Config.redacted("API_KEY") // Returns Redacted<string>
const secret = Redacted.value(yield* apiKey) // Unwrap when needed
// Nested configuration with prefix
const dbConfig = Config.all({
host: Config.string("HOST"),
port: Config.number("PORT"),
name: Config.string("NAME"),
}).pipe(Config.nested("DATABASE")) // DATABASE_HOST, DATABASE_PORT, etc.
// Using config in effects
const program = Effect.gen(function* () {
const p = yield* Config.number("PORT")
const key = yield* Config.redacted("API_KEY")
return { port: p, apiKey: Redacted.value(key) }
})
// Custom config provider (e.g., from object instead of env)
const customProvider = ConfigProvider.fromMap(
new Map([["PORT", "3000"], ["API_KEY", "secret"]])
)
const withCustomConfig = Effect.provide(
program,
Layer.setConfigProvider(customProvider)
)
// Config validation and transformation
const validPort = Config.number("PORT").pipe(
Config.validate({
message: "Port must be between 1 and 65535",
validation: (n) => n >= 1 && n <= 65535,
})
)Array Operations
import { Array as Arr, Order } from "effect"
// Sorting with built-in orderings (accepts any Iterable)
Arr.sort([3, 1, 2], Order.number) // [1, 2, 3]
Arr.sort(["b", "a", "c"], Order.string) // ["a", "b", "c"]
Arr.sort(new Set([3n, 1n, 2n]), Order.bigint) // [1n, 2n, 3n]
// Sort by derived value
Arr.sortWith(users, (u) => u.age, Order.number)
// Sort by multiple criteria
Arr.sortBy(
users,
Order.mapInput(Order.number, (u: User) => u.age),
Order.mapInput(Order.string, (u: User) => u.name)
)
// Built-in orderings: Order.string, Order.number, Order.bigint, Order.boolean, Order.Date
// Reverse ordering: Order.reverse(Order.number)Utility Functions
import { constVoid as noop } from "effect/Function"
// constVoid returns undefined, useful as a no-operation callback
noop() // undefined
// Common use cases:
Effect.tap(effect, noop) // Ignore value, just run effect
Promise.catch(noop) // Swallow errors
eventEmitter.on("event", noop) // Register empty handlerDeprecations
- `BigDecimal.fromNumber` — Use
BigDecimal.unsafeFromNumberinstead (3.11.0+) - `Schema.annotations()` — Now removes previously set identifier annotations; identifiers are tied to the schema's
ast reference only (3.17.10)
Additional Resources
Local Effect Resources
- `~/.effect/packages/effect/src/` — Core Effect modules and implementation
External Resources
- Effect-Atom — https://github.com/tim-smart/effect-atom (open in browser for reactive state management patterns)
Reference Files
- `./references/critical-rules.md` — Forbidden patterns and mandatory conventions
- `./references/effect-atom.md` — Effect-Atom reactive state management for React
- `./references/next-js.md` — Effect + Next.js 15+ App Router integration patterns
- `./references/option-null.md` — Option vs null boundary patterns
- `./references/streams.md` — Stream patterns and backpressure gotchas
- `./references/testing.md` — Vitest deterministic testing patterns
Critical Rules for Effect-TS
These rules address common mistakes when working with Effect. Understanding why they matter helps write idiomatic Effect code.
INEFFECTIVE: try-catch in Effect.gen
Avoid `try-catch` blocks inside `Effect.gen` generators for handling Effect failures.
Effect failures are returned as exits, not thrown as JavaScript exceptions. Using try-catch will not catch Effect failures—it only catches synchronous throws from non-Effect code.
Problematic:
Effect.gen(function* () {
try {
const result = yield* someEffect;
} catch (error) {
// This catches synchronous throws only, NOT Effect failures
// Effect failures bypass this entirely
}
});Correct:
Effect.gen(function* () {
const result = yield* Effect.result(someEffect);
if (result._tag === "Failure") {
// Handle error case
}
});Alternative patterns:
Effect.catchAll/Effect.catchTagfor error recoveryEffect.resultto inspect success/failureEffect.tryPromise/Effect.tryfor wrapping external code
AVOID: Type Assertions
Avoid `as never`, `as any`, or `as unknown` type assertions.
These break TypeScript's type safety and hide real type errors. Always fix the underlying type issues instead.
Patterns to avoid:
const value = something as any;
const value = something as never;
const value = something as unknown;Correct approach:
- Use proper generic type parameters
- Import correct types from Effect
- Use proper Effect constructors and combinators
- Adjust function signatures to match usage
Note: This is general TypeScript guidance. Occasional assertions may be justified when interfacing with poorly-typed external libraries, but document the reason.
RECOMMENDED: return yield* for Errors
*Use `return yield` when yielding errors or interrupts in Effect.gen for clarity.**
The runtime halts on failed yields regardless of return, but the explicit return makes termination obvious and prevents unreachable-code warnings.
Recommended:
Effect.gen(function* () {
if (someCondition) {
return yield* Effect.fail("error message");
}
if (shouldInterrupt) {
return yield* Effect.interrupt;
}
const result = yield* someOtherEffect;
return result;
});Acceptable but less clear:
Effect.gen(function* () {
if (someCondition) {
yield* Effect.fail("error message");
// Runtime halts here, but looks like code might continue
}
});The return keyword makes termination explicit and improves code readability.
Null vs Option<T> Rule
Use `Option<T>` internally, `T | null` at boundaries.
- Internal Effect computations →
Option<T> - React state/props →
T | null - JSON serialization →
T | nullorT | undefined - External API responses → normalize to
Option<T>at boundary
See OPTION_NULL.md for comprehensive patterns.
Effect-Atom Reference
Reactive state management library for Effect. Provides atoms (reactive state containers) that integrate with Effect's functional programming ecosystem and React.
Source code: https://github.com/tim-smart/effect-atom (open in browser)
Core API
Creating Atoms
import { Atom } from "@effect-atom/atom-react"
// Simple value atom
const countAtom = Atom.make(0)
// Derived atom (computed from other atoms)
const doubleAtom = Atom.make((get) => get(countAtom) * 2)
// Effectful atom (returns Result type)
const userAtom = Atom.make(
Effect.gen(function* () {
const api = yield* Api
return yield* api.fetchUser()
})
)
// Keep value when component unmounts (prevents reset)
const persistentAtom = Atom.make(0).pipe(Atom.keepAlive)React Hooks
import { useAtomValue, useAtomSet, useAtom } from "@effect-atom/atom-react"
function Counter() {
// Read-only access
const count = useAtomValue(countAtom)
// Write-only access
const setCount = useAtomSet(countAtom)
// Read and write access
const [value, setValue] = useAtom(countAtom)
return <button onClick={() => setCount((n) => n + 1)}>{count}</button>
}Atom Families
Generate stable atom references for dynamic keys:
const userAtomFamily = Atom.family((userId: string) =>
Atom.make(
Effect.gen(function* () {
const api = yield* Api
return yield* api.fetchUser(userId)
})
)
)
// Usage
const userAtom = userAtomFamily("user-123")Atom Functions
Create callable effects:
const incrementFn = Atom.fn(
Effect.gen(function* () {
const count = yield* Ref.get(counterRef)
yield* Ref.set(counterRef, count + 1)
})
)
// Invoke with useAtomSet
const increment = useAtomSet(incrementFn)
increment() // Returns Promise<Exit<...>>Atom Runtime
Create atom runtime from Effect layers for dependency injection:
const runtimeAtom = Atom.runtime(ApiLive)
function App() {
return (
<AtomProvider runtime={runtimeAtom}>
<MyComponent />
</AtomProvider>
)
}Advanced Features
URL Search Parameters
Bind atoms to URL search parameters:
const pageAtom = Atom.searchParam("page", {
decode: (s) => parseInt(s ?? "1", 10),
encode: (n) => n.toString(),
})Local Storage Persistence
const settingsAtom = Atom.kvs({
key: "app-settings",
defaultValue: { theme: "dark" },
})Scoped Resources
Add finalizers for cleanup when atom rebuilds or unmounts:
const websocketAtom = Atom.make((get) =>
Effect.gen(function* () {
const ws = yield* WebSocket.connect("wss://...")
yield* Effect.addFinalizer(() => ws.close())
return ws
})
)Event Listeners with Self-Update
const windowSizeAtom = Atom.make((get) =>
Effect.gen(function* () {
const handler = () =>
get.setSelf({ width: window.innerWidth, height: window.innerHeight })
window.addEventListener("resize", handler)
yield* Effect.addFinalizer(() =>
Effect.sync(() => window.removeEventListener("resize", handler))
)
return { width: window.innerWidth, height: window.innerHeight }
})
)Reactivity Keys
Trigger cache invalidation:
const dataAtom = Atom.make(
Effect.gen(function* () {
const keys = yield* Atom.withReactivity(["data-key"])
// Re-runs when "data-key" is invalidated
return yield* fetchData()
})
)RPC and HTTP API Integration
// RPC client
const rpcClient = AtomRpc.Tag()
// HTTP API client
const httpClient = AtomHttpApi.Tag()Result Handling
Effectful atoms return Result types. Handle with pattern matching:
function UserProfile() {
const userResult = useAtomValue(userAtom)
return Result.match(userResult, {
onSuccess: (user) => <div>{user.name}</div>,
onFailure: (error) => <div>Error: {error.message}</div>,
})
}Mutation Results
Use mode: "promiseExit" for mutation handling:
const saveUser = useAtomSet(saveUserAtom, { mode: "promiseExit" })
const handleSave = async () => {
const exit = await saveUser(userData)
if (Exit.isSuccess(exit)) {
// Handle success
}
}Streams
Pull values from streams:
const messagesAtom = Atom.pull(messageStream)Best Practices
1. Use `Atom.family` for dynamic keys — Generates stable references, avoids memory leaks 2. Apply `Atom.keepAlive` for persistent state — Prevents reset on unmount 3. Use `Atom.runtime` for dependency injection — Integrates Effect layers with React context 4. Implement finalizers for cleanup — Ensures proper resource management 5. Use `mode: "promiseExit"` for mutations — Provides typed success/failure handling 6. Prefer derived atoms over component state — Keeps state logic centralized
Effect + Next.js Integration
@prb/effect-next provides typed helpers for integrating Effect with Next.js 15+ App Router—route handlers, server actions, middleware, and React hooks.
Core API
Route Handlers
// app/api/users/[id]/route.ts
import { effectHandler } from "@prb/effect-next/handlers";
import { Effect } from "effect";
import { RouteParams } from "@prb/effect-next/params";
export const GET = effectHandler(
Effect.gen(function* () {
const params = yield* RouteParams;
const user = yield* fetchUser(params.id);
return Response.json(user);
}),
AppLayer
);Server Actions
"use server";
import { effectAction } from "@prb/effect-next/action";
import { Effect } from "effect";
export const createUser = effectAction(
Effect.gen(function* () {
const db = yield* Database;
return yield* db.insert(users).values({ name: "Alice" });
}),
AppLayer
);
// Returns Exit-like result with _tag: "Success" | "Failure"
const result = await createUser();
if (result._tag === "Success") {
console.log(result.value);
}Middleware
// middleware.ts
import { effectMiddleware } from "@prb/effect-next/middleware";
import { Effect, Layer } from "effect";
import { Headers } from "@prb/effect-next/headers";
const AuthLayer = Layer.effect(
AuthService,
Effect.gen(function* () {
const headers = yield* Headers;
const token = headers.get("authorization");
if (!token) yield* Effect.fail({ _tag: "Unauthorized" });
return { validateToken: () => Effect.succeed(true) };
})
);
export const middleware = effectMiddleware(
Effect.gen(function* () {
yield* AuthService;
return NextResponse.next();
}),
AuthLayer
);React Hooks
Client-side hooks for running Effects in React components.
"use client";
import {
EffectNextProvider,
useEffectNextRuntime,
useEffectMemo,
useEffectOnce,
useForkEffect,
useStream,
useStreamLatest,
useSubscriptionRef
} from "@prb/effect-next/react-hooks";
// Wrap app with provider
<EffectNextProvider runtime={runtime}>{children}</EffectNextProvider>;
// Access runtime
const runtime = useEffectNextRuntime();
// Run Effect with dependencies (like useMemo)
const data = useEffectMemo(() => effect, [deps], runtime);
// Run Effect once on mount
const data = useEffectOnce(effect, runtime);
// Run Effect in background (fire-and-forget)
useForkEffect(effect, runtime, [deps]);
// Subscribe to Stream
const values = useStream(stream, runtime);
const latest = useStreamLatest(stream, runtime, initialValue);
// Subscribe to SubscriptionRef
const value = useSubscriptionRef(ref, runtime);Request-Scoped Cache
Leverage React's cache() for request deduplication.
import { reactCache, reactCacheFn, reactCacheWithKey } from "@prb/effect-next/cache";
import { ManagedRuntime } from "effect";
const runtime = ManagedRuntime.make(AppLayer);
// Cache an Effect
export const getUsers = reactCache(
Effect.gen(function* () {
const db = yield* Database;
return yield* db.query("SELECT * FROM users");
}),
runtime
);
// Cache a function with arguments
export const getUserById = reactCacheFn((id: string) =>
Effect.gen(function* () {
const db = yield* Database;
return yield* db.query(`SELECT * FROM users WHERE id = ${id}`);
}),
runtime
);
// Cache with custom key
export const getUser = reactCacheWithKey(
(opts) => fetchUserEffect(opts),
(opts) => `user:${opts.id}`,
runtime
);Headers & Cookies
import { Headers, Cookies } from "@prb/effect-next/headers";
Effect.gen(function* () {
const headers = yield* Headers;
const userAgent = headers.get("user-agent");
const cookies = yield* Cookies;
const sessionId = cookies.get("sessionId");
});Params
import { RouteParams, SearchParams } from "@prb/effect-next/params";
Effect.gen(function* () {
const params = yield* RouteParams;
const userId = params.id;
const searchParams = yield* SearchParams;
const page = searchParams.page;
});Navigation
import { redirect, rewrite, notFound } from "@prb/effect-next/navigation";
Effect.gen(function* () {
yield* redirect("/login");
yield* rewrite("/new-path");
yield* notFound();
});Testing Kit
import {
assertRight,
assertLeft,
expectTaggedFailure,
expectDefect,
runExpectSuccess,
runExpectFailure,
makeMockRuntime
} from "@prb/effect-next/testing-kit";
// Assert success
test("should succeed", async () => {
const exit = await Effect.runPromiseExit(effect);
const value = assertRight(exit);
expect(value).toBe(42);
});
// Assert specific failure tag
test("should fail with NotFound", async () => {
const exit = await Effect.runPromiseExit(effect);
expectTaggedFailure(exit, "NotFound");
});
// Run and expect success
test("should create user", async () => {
const user = await runExpectSuccess(createUser(), runtime);
expect(user.name).toBe("Alice");
});Best Practices
1. Use `Effect.fn()` — Automatic telemetry spans and better stack traces 2. Centralize layers — Create AppLayer with all shared services 3. Error handling — Use .catchAll() or .catchTag() for Effect-level errors 4. Request caching — Use reactCache for request-scoped memoization 5. Server-only Effect — Effect-ts shines server-side; avoid complex Effect in client components
Option vs Null Patterns
The Rule
Use Option<T> for Effect domain logic. Use T | null only at external boundaries.
When to Use Option<T>
- Internal Effect computations
- Domain models where absence has meaning
- Function returns that may not produce a value
- Chain operations that may fail to produce a value
When to Use T | null
- React state/props (hooks expect nullable primitives)
- JSON serialization (Option doesn't serialize to JSON)
- External API responses
- Database query results
- localStorage/sessionStorage values
Boundary Normalization
import { Option } from "effect"
// Incoming: null → Option (at API/storage boundary)
const fromApi = Option.fromNullable(response.data)
const fromStorage = Option.fromNullable(localStorage.getItem("key"))
// Outgoing: Option → null (for React/JSON)
const toReact = Option.getOrNull(maybeValue)
const toJson = Option.getOrUndefined(maybeValue)Common Patterns
// Map over optional value
Option.map(maybeUser, (user) => user.name)
// Chain optional operations
Option.flatMap(maybeUser, (user) => Option.fromNullable(user.profile))
// Provide default
Option.getOrElse(maybeValue, () => defaultValue)
// Check and extract
if (Option.isSome(maybeValue)) {
console.log(maybeValue.value) // Safe access
}Avoid Option\<Option<T>> Creep
// WRONG: Nested options from repeated normalization
const bad = Option.fromNullable(Option.fromNullable(x))
// RIGHT: Normalize once at the boundary
const good = Option.fromNullable(x)
// If you have nested options, flatten them
const flattened = Option.flatten(nestedOption)Schema Decoding
import { Schema } from "effect"
// Optional field with Option type
const UserSchema = Schema.Struct({
name: Schema.String,
nickname: Schema.optionalWith(Schema.String, { as: "Option" })
})
// nickname will be Option<string>
// Optional field with null (for JSON compat)
const ApiUserSchema = Schema.Struct({
name: Schema.String,
nickname: Schema.NullOr(Schema.String)
})
// nickname will be string | nullEffect-Atom Integration
// Atoms with nullable state (for React compat)
const userAtom = Atom.make<User | null>(null)
// Convert at boundaries
const program = Effect.gen(function* () {
const maybeUser = yield* fetchUser() // Returns Option<User>
return Option.getOrNull(maybeUser) // Convert for React
})Stream Patterns
Streams are lazy, pull-based sequences of values that can be infinite. Handle with care.
Create Streams
import { Stream } from "effect"
// From values
Stream.make(1, 2, 3)
// From iterable
Stream.fromIterable([1, 2, 3])
// Single value from effect
Stream.fromEffect(fetchUser())
// Infinite stream from repeated effect
Stream.repeatEffect(Effect.sync(() => Math.random()))
// From async iterable
Stream.fromAsyncIterable(asyncGenerator(), (error) => new StreamError({ cause: error }))
// Chunks for efficiency
Stream.fromChunk(Chunk.make(1, 2, 3))Consume Streams
// Collect all values (DANGEROUS for infinite streams)
const allValues = yield* Stream.runCollect(stream) // Returns Chunk<A>
// Process each element
yield* Stream.runForEach(stream, (value) => Effect.log(`Got: ${value}`))
// Fold/reduce
const sum = yield* Stream.runFold(stream, 0, (acc, n) => acc + n)
// First element only
const first = yield* Stream.runHead(stream) // Returns Option<A>
// Drain (run for side effects, discard values)
yield* Stream.runDrain(stream)Bound Consumption (Critical for Safety)
// WRONG: Hangs forever on infinite stream
yield* Stream.runCollect(infiniteStream)
// RIGHT: Take first N elements
yield* Stream.runCollect(Stream.take(infiniteStream, 100))
// RIGHT: Take until condition
yield* Stream.runCollect(Stream.takeUntil(stream, (x) => x > 100))
// RIGHT: Take while condition holds
yield* Stream.runCollect(Stream.takeWhile(stream, (x) => x < 100))
// RIGHT: Apply timeout
yield* Stream.runCollect(stream).pipe(Effect.timeout("5 seconds"))Transform Streams
// Map values
Stream.map(stream, (x) => x * 2)
// Filter values
Stream.filter(stream, (x) => x > 0)
// FlatMap (each value produces a stream)
Stream.flatMap(userIds, (id) => Stream.fromEffect(fetchUser(id)))
// Tap for side effects
Stream.tap(stream, (x) => Effect.log(`Processing: ${x}`))
// Scan (running fold)
Stream.scan(stream, 0, (acc, x) => acc + x) // Emits running totalsChunk and Batch
// Group into chunks of N
Stream.grouped(stream, 100) // Stream<Chunk<A>>
// Group by time window
Stream.groupedWithin(stream, 100, "1 second")
// Rechunk for efficiency
Stream.rechunk(stream, 1000)Handle Errors in Streams
// Catch errors and recover
Stream.catchAll(stream, (error) => Stream.make(fallbackValue))
// Retry on failure
Stream.retry(stream, Schedule.exponential("100 millis"))
// Handle specific error tags
Stream.catchTag(stream, "NetworkError", (e) => Stream.empty)Resource Safety
// Bracket pattern for streams
Stream.acquireRelease(
acquire, // Effect<Resource, E, R>
release // (resource: Resource) => Effect<void>
)
// Scoped stream (resource released when stream completes)
Stream.scoped(Effect.acquireRelease(open, close))
// Finalizer
Stream.ensuring(stream, cleanup)Common Gotchas
1. Infinite streams: Always bound consumption with take, takeUntil, or timeout 2. Backpressure: Streams are pull-based; slow consumers automatically apply backpressure 3. Resource leaks: Use scoped/bracket patterns for resources 4. Chunking overhead: Rechunk for better performance with small items 5. Error propagation: Errors terminate the stream; use catchAll to recover
Testing Effect-TS (Vitest) — Reference
This is a pragmatic guide for writing _deterministic_ tests in Effect-TS codebases, especially when using @effect/vitest.
The #1 gotcha: it.effect uses TestClock
@effect/vitest's it.effect runs your test with a TestContext (including `TestClock`).
Implications:
- Time starts at 0.
- Time does not pass unless you advance it.
- Any
Effect.sleep(...),Schedule.spaced(...), retry backoff, polling loop, etc. will stall forever unless you callTestClock.adjust(...).
Use it.live when you truly want wall-clock time.
Time: don't use Date.now() in Effect code
If production code uses Date.now(), it becomes hard (or impossible) to test deterministically under TestClock.
Prefer Effect's clock service:
import { Clock, Effect } from "effect";
const nowMillis = Clock.currentTimeMillis;
const program = Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
return now;
});That makes your code controllable via TestClock.
Replace Effect.sleep with TestClock.adjust (under it.effect)
Instead of:
yield * Effect.sleep("50 millis");do:
import { TestClock } from "effect";
yield * TestClock.adjust("50 millis");If you _must_ use real timers (e.g. testing integration with Node timers), switch the whole test to it.live.
Testing retries / backoff / scheduled loops
Retry schedules and Schedule.spaced(...) don't progress under TestClock unless you advance time.
A reliable pattern is:
import { Effect, Fiber, TestClock } from "effect";
const runWithTime = <A, E, R>(
effect: Effect.Effect<A, E, R>,
adjust: Parameters<typeof TestClock.adjust>[0] = "1000 millis"
) =>
Effect.gen(function* () {
const fiber = yield* Effect.fork(effect);
yield* TestClock.adjust(adjust);
return yield* Fiber.join(fiber);
});Advance _enough_ time for the whole schedule/backoff chain to complete.
Streams, watches, and background fibers: always bound + cleanup
Most test “hangs” in Effect come from one of these:
- A stream that never ends (
Stream.runCollect(stream)on an infinite stream) - A watch/polling loop forked and never interrupted
- A scoped resource that never gets finalized because the scope never closes
Recommendations:
- Prefer bounded consumption:
Stream.take(stream, n)/Stream.takeUntil(...). - If you fork a fiber, ensure it is interrupted on all paths:
yield* Fiber.interrupt(fiber)- or run it inside a
Scopeand let scope finalizers do the cleanup. - Consider
Effect.timeout(...)/Effect.timeoutFail(...)around anything that could block.
Concurrency gotcha: Effect.fork does not mean “the fiber has started”
When you write a test like:
- fork 2–3 fibers
- then immediately
Deferred.succeed(gate, ...)
…you have not guaranteed that the forked fibers have reached the code you intend to coordinate (e.g. Deferred.await(gate)).
Effect.fork creates a fiber and schedules it, but the scheduler may not run it until later. If you open the gate too early:
- each fiber can observe the gate as already-open
- your “concurrent” test can become effectively sequential
- assertions like “underlying effect executed once” can fail intermittently even though the implementation is correct
Deterministic pattern: started latch + gate
If you need to ensure real overlap, add a second Deferred that the underlying effect completes as soon as it begins:
import { Deferred, Effect, Fiber } from "effect";
Effect.gen(function* () {
let executions = 0;
const started = yield* Deferred.make<void>();
const gate = yield* Deferred.make<void>();
const underlying = Effect.gen(function* () {
executions++;
// Signal we actually started executing (at least one fiber is “in” now)
yield* Deferred.succeed(started, undefined);
// Block here to force overlap
yield* Deferred.await(gate);
return "ok";
});
const f1 = yield* Effect.fork(underlying);
const f2 = yield* Effect.fork(underlying);
// Don't open the gate until at least one fiber definitely started
yield* Deferred.await(started);
yield* Deferred.succeed(gate, undefined);
yield* Fiber.join(f1);
yield* Fiber.join(f2);
// Now it's safe to assert expectations about overlap / dedup / sharing
// expect(executions).toBe(1)
});This avoids “we opened the gate before any fiber ran” flakiness and makes concurrency assertions reliable.
Use it.scoped when your test allocates scoped resources
If your test (or the code under test) uses Effect.acquireRelease, Stream.asyncScoped, resourceful Layers, etc., prefer it.scoped / it.scopedLive so finalizers are guaranteed to run when the test completes.
Don't “escape” the test runtime inside an Effect test
Avoid calling Effect.runPromise(...) (or similar “run” APIs) _inside_ an it.effect program to drive internal logic. It can accidentally run work on a different runtime (e.g. a live clock), defeating TestClock determinism.
Prefer staying inside the Effect you're already running:
- pass
Effects around andyield*them - if you truly need a Promise boundary, do it at the test boundary, not mid-program
Quick decision table
- Uses timeouts/sleeps/retries/polling? →
it.effect+TestClock.adjust(...) - Needs wall clock / Node timers / real delays? →
it.live(orit.scopedLive) - Allocates resources that must be finalized? →
it.scoped/it.scopedLive