| name | effect.ts-architect |
| description | Expert in Effect.ts architecture, design patterns, and best practices for building production-grade TypeScript applications using Effect's functional effect system. Covers dependency injection, error handling, composition, and integration patterns. |
| category | framework |
| displayName | Effect.ts Architect |
| color | purple |
Effect.ts Architecture Skill
Purpose
This skill teaches AI assistants how to architect and build production-grade TypeScript applications using Effect.ts, a functional effect system that provides type-safe, composable effects with built-in dependency injection, error handling, and resource management.
Core Philosophy: Effect.ts enables explicit dependency management, type-safe error handling, and composable program construction through its Layer-based dependency injection system.
Core Concepts
1. Effect: The Foundation
An Effect represents a computation that may:
- Succeed with a value of type
A
- Fail with an error of type
E
- Require services/dependencies of type
R
- Perform side effects (IO, async operations, etc.)
type Effect<A, E, R> = {
}
2. Context: Dependency Requirements
The R type parameter represents required services (dependencies). Effect uses a Context system to manage these dependencies.
Use the Tag type directly in R, not typeof Tag:
const program: Effect.Effect<Data, MyError, UsersServiceTag | DatabaseService> = ...
const program: Effect.Effect<Data, MyError, typeof UsersServiceTag | DatabaseService> = ...
When you provide the layer, R becomes never:
const provided: Effect.Effect<Data, Error, never> = program.pipe(
Effect.provide(HttpClientLayer)
)
3. Layer: Dependency Injection
Layers are the primary mechanism for dependency injection in Effect.ts. They:
- Define how to create services
- Can depend on other services
- Are composable and reusable
- Manage service lifecycle
Layer Type Structure:
Layer<RequirementsOut, Error, RequirementsIn>
RequirementsOut: The service(s) this layer produces
Error: Possible errors during layer construction
RequirementsIn: Dependencies this layer needs to construct the service
Naming Conventions:
Live suffix for production implementations (e.g., DatabaseLive, HttpClientLive)
Test suffix for test implementations (e.g., DatabaseTest, HttpClientTest)
File and entity placement:
- One service (Tag) per file: Put the
Context.Tag definition in a file named after the service in PascalCase (e.g. InferOrgsMainName.ts, InferEntitiesStreams.ts). Export the Tag class and any types used by the interface (e.g. UserMainNamePair, OrgRow).
- Live layer in a separate file: Put the production implementation in a file named
<ServiceName>Live.ts (e.g. InferOrgsMainNameLive.ts). This keeps the port (Tag + interface) separate from the adapter (Live implementation).
- Avoid “Effect” in file names: Prefer capability-based names (
InferOrgsMainName, InferUsersMainName, InferEntitiesStreams) over names that mention “Effect” (e.g. avoid inferOrgsMainNameEffect.ts). The fact that the API returns Effect/Stream is implied.
- Stream helpers as a service: When you have paginated or entity streams used by multiple use cases, expose them via a dedicated service (e.g.
InferEntitiesStreams with streamOfOrganizations / streamOfUsers) rather than free functions in a “streams” file. Callers then depend on the service and stay testable.
const ConfigLive = Layer.succeed(
Config.Config,
{ apiKey: process.env.API_KEY }
)
import { FetchHttpClient } from '@effect/platform'
const HttpClientLayer = FetchHttpClient.layer
4. Services: Type-Safe Dependencies
Services are accessed via yield* in Effect.gen:
const program = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get('https://api.example.com')
return yield* response.json
})
Architectural Patterns
Pattern 1: Pure Effect Functions
Best Practice: Write functions that return Effects, not async functions that create Effects internally.
export const getWalletRiskScore = (
address: string,
): Effect.Effect<
unknown,
HttpClientError.HttpClientError | TimeoutException,
HttpClient.HttpClient
> =>
pipe(
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(url).pipe(
Effect.timeout('10 seconds'),
Effect.retry({ schedule: Schedule.exponential(1000), times: 3 }),
)
return yield* response.json
}),
Effect.withSpan('getWalletRiskScore', { attributes: { address } }),
)
export async function getWalletRiskScore(address: string) {
const httpClient = HttpClient.make(...)
return await Effect.runPromise(
Effect.provideService(program, HttpClient.HttpClient, httpClient)
)
}
Why: Pure Effect functions are:
- Easier to test (provide mock dependencies)
- More composable (can be combined with other Effects)
- Type-safe (dependencies are explicit in type signature)
Pattern 2: Avoiding Requirement Leakage
Critical Principle: Service interfaces should NOT expose dependencies. Service operations should have R = never in their type signatures. Dependencies are managed at the Layer construction level, not the service level.
class Database extends Context.Tag("Database")<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown, never, Config | Logger>
}
>()
const test = Effect.gen(function* () {
const db = yield* Database
const result = yield* db.query("SELECT * FROM users")
})
class Database extends Context.Tag("Database")<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown>
}
>()
const DatabaseLive = Layer.effect(
Database,
Effect.gen(function* () {
const config = yield* Config
const logger = yield* Logger
return {
query: (sql: string) => Effect.gen(function* () {
yield* logger.log(`Executing: ${sql}`)
return { result: 'data' }
})
}
})
)
const test = Effect.gen(function* () {
const db = yield* Database
const result = yield* db.query("SELECT * FROM users")
})
Why: This architectural principle:
- Keeps service interfaces clean and focused
- Makes services easier to test (no need to provide unrelated dependencies)
- Prevents dependency leakage throughout the codebase
- Enables better composition and reuse
Pattern 3: Layer Composition
Best Practice: Compose layers to build your application's dependency graph. Understand when to merge vs compose layers.
Creating Layers with Dependencies
Use Layer.effect when a service depends on other services:
import { Layer, Effect } from 'effect'
const ConfigLive = Layer.succeed(
Config.Config,
{ apiKey: process.env.API_KEY }
)
const LoggerLive = Layer.effect(
Logger.Logger,
Effect.gen(function* () {
const config = yield* Config.Config
return {
log: (message: string) => Effect.gen(function* () {
const { logLevel } = yield* config.getConfig
console.log(`[${logLevel}] ${message}`)
})
}
})
)
const DatabaseLive = Layer.effect(
Database.Database,
Effect.gen(function* () {
const config = yield* Config.Config
const logger = yield* Logger.Logger
return {
query: (sql: string) => Effect.gen(function* () {
yield* logger.log(`Executing: ${sql}`)
return { result: 'data' }
})
}
})
)
Merging vs Composing Layers
Merging (Layer.merge / Layer.mergeAll): Combines layers concurrently
- Both outputs and inputs are combined (union types)
- Used when layers don't depend on each other
- Layers run in parallel
const AppConfigLive = Layer.mergeAll(
ConfigLive,
HttpClientLive
)
Composing (Layer.provide): Sequential composition
- Output of one layer feeds into input of another
- Used when one layer depends on another
- Resolves dependency graph
const AppConfigLive = Layer.mergeAll(
ConfigLive,
LoggerLive
)
const MainLive = DatabaseLive.pipe(
Layer.provide(AppConfigLive),
Layer.provide(ConfigLive)
)
Complete Example:
import { Layer, Effect } from 'effect'
import { FetchHttpClient } from '@effect/platform'
const ConfigLive = Layer.succeed(Config.Config, { apiKey: 'key' })
const HttpClientLive = FetchHttpClient.layer
const LoggerLive = Layer.effect(
Logger.Logger,
Effect.gen(function* () {
const config = yield* Config.Config
return { log: (msg: string) => console.log(msg) }
})
)
const DatabaseLive = Layer.effect(
Database.Database,
Effect.gen(function* () {
const logger = yield* Logger.Logger
return { query: (sql: string) => Effect.succeed([]) }
})
)
const AppLayer = DatabaseLive.pipe(
Layer.provide(LoggerLive),
Layer.provide(ConfigLive),
Layer.provideMerge(HttpClientLive)
)
const program = Effect.gen(function* () {
const db = yield* Database.Database
const client = yield* HttpClient.HttpClient
})
const result = await Effect.runPromise(
program.pipe(Effect.provide(AppLayer))
)
Why: Layer composition:
- Makes dependencies explicit in type signatures
- Enables easy swapping (test vs production)
- Ensures all dependencies are provided
- Type-safe dependency resolution
Pattern 4: Error Handling with Effect
Best Practice: Use Effect's error types, not try/catch. Define error types clearly and verbosely—avoid a single generic Error or opaque union; list concrete error types so callers can handle or map them.
export const getWalletRiskScore = (
address: string,
): Effect.Effect<
unknown,
HttpClientError.HttpClientError | TimeoutException,
HttpClient.HttpClient
> => ...
export const doSomething = (): Effect.Effect<Data, Error, R> => ...
Specify the error union on the Effect itself (inline), not as a separate named type:
export const getUser = (
id: string,
): Effect.Effect<
User,
UserNotFoundError | ValidationError | DatabaseConnectionError,
DatabaseService
> => ...
const program = getWalletRiskScore(address).pipe(
Effect.catchAll((error) => {
if (error instanceof HttpClientError.RequestError) {
return Effect.succeed(defaultValue)
}
return Effect.fail(error)
}),
Effect.retry({ times: 3 }),
Effect.timeout('10 seconds')
)
Why: Effect's error handling:
- Type-safe (errors are in type signature)
- Composable (can chain error handlers)
- Explicit (no hidden exceptions)
Pattern 4: Integration with Async Code
Best Practice: Convert async functions to Effects at boundaries, not internally.
export const calculateRiskProfileScore = (
primaryRiskScoreId: string,
additionalData: Record<string, any> = {},
): Effect.Effect<number, Error, HttpClient.HttpClient> =>
Effect.gen(function* () {
const walletScore = yield* getWalletRiskScore(additionalData.walletAddress)
return finalScore
})
export async function calculateRiskProfileScore(...) {
try {
const response = await Effect.runPromise(
getWalletRiskScore(walletAddress).pipe(
Effect.provide(FetchHttpClient.layer)
)
)
} catch (error) {
}
}
Why: Keep Effect boundaries clean:
- Easier to test (all dependencies explicit)
- Better composition (can combine with other Effects)
- Type-safe error handling
Pattern 5: Singleton Application Layer for React/Frontend
When building React or frontend applications, create a singleton application layer that provides all services. This ensures consistent service instances across your app.
import { FetchHttpClient, type HttpClient } from '@effect/platform'
import { Layer, Logger, LogLevel } from 'effect'
export type AppServices =
| HttpClient.HttpClient
| AuthenticationService
| EntityApiService
const LoggerLayer = Logger.minimumLogLevel(LogLevel.Trace)
const HttpClientLayer = FetchHttpClient.layer
export function buildApplicationLayer() {
const authStoreLayer = getAuthenticationStateStoreLayer()
const BaseLayer = Layer.mergeAll(
authStoreLayer,
HttpClientLayer,
TokenStorageLive,
AuthenticationLive.pipe(
Layer.provide(authStoreLayer),
Layer.provide(HttpClientLayer),
Layer.provide(TokenStorageLive),
),
)
const ServicesLayer = Layer.mergeAll(
EntityApiLive,
RpcApiLive,
).pipe(Layer.provide(BaseLayer))
return Layer.mergeAll(BaseLayer, ServicesLayer, LoggerLayer)
}
let applicationLayer: Layer.Layer<AppServices, never, never> | undefined
export function getApplicationLayer() {
if (applicationLayer === undefined) {
applicationLayer = buildApplicationLayer()
}
return applicationLayer
}
Why: Singleton layer:
- Ensures consistent service instances (same HttpClient, auth state, etc.)
- Simplifies Effect.provide calls throughout the app
- Enables easy testing with layer overrides
- Provides single source of truth for dependencies
Use Cases:
- React/frontend applications (see
effect.ts-react skill)
- CLI applications
- Any application where services should be shared globally
Pattern 6: Resource Management
Best Practice: Use Effect's resource management for cleanup.
import { Effect, Scope } from 'effect'
const program = Effect.gen(function* () {
const resource = yield* Effect.acquireUseRelease(
acquireResource(),
(resource) => useResource(resource),
(resource) => cleanupResource(resource),
)
return resource
})
Why: Effect ensures:
- Resources are always cleaned up
- Cleanup runs even on errors
- Type-safe resource management
Reactive Stores for React Integration
When building React applications with Effect.ts, use reactive stores to bridge Effect services and React components. This pattern allows Effect services to update state that React components can subscribe to.
Pattern: defineStore for Effect-React Bridge
import { Context, Effect, Layer, Stream, Chunk } from 'effect'
export interface ReactiveStore<A> {
readonly get: () => Effect.Effect<A, never, never>
readonly update: (f: (a: A) => A) => Effect.Effect<void, never, never>
readonly changes: Stream.Stream<A, never, never>
}
export function defineStore<A>(
name: string,
initial: A,
): {
tag: Context.Tag<ReactiveStore<A>, ReactiveStore<A>>
layer: Layer.Layer<ReactiveStore<A>, never, never>
} {
const tag = Context.GenericTag<ReactiveStore<A>>(name)
let current: A = initial
const changeListeners = new Set<(a: A) => void>()
const notify = (a: A) => {
current = a
changeListeners.forEach((l) => l(a))
}
const changes = Stream.async<A, never, never>((emit) => {
emit(Effect.succeed(Chunk.of(current)))
const listener = (a: A) => {
emit(Effect.succeed(Chunk.of(a)))
}
changeListeners.add(listener)
return Effect.sync(() => {
changeListeners.delete(listener)
})
})
const store: ReactiveStore<A> = {
get: () => Effect.succeed(current),
update: (f: (a: A) => A) => Effect.sync(() => notify(f(current))),
changes,
}
const layer = Layer.sync(tag, () => store)
return { tag, layer }
}
Using Reactive Stores with Services
Services can depend on reactive stores and update them:
const AuthenticationLive = Layer.effect(
Authentication,
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const store = yield* AuthStoreTag
return {
login: (email: string, password: string) =>
Effect.gen(function* () {
const response = yield* client.post('/api/auth/login', { email, password })
const user = yield* response.json
yield* store.update(() => ({
user: Option.some(user),
token: Option.some(user.token),
}))
return user
}),
}
}),
)
Why: Reactive stores:
- Bridge the gap between Effect services and React components
- Provide type-safe, reactive state management
- Work seamlessly with Effect's dependency injection
- Enable React components to subscribe to Effect-managed state
Use Cases:
- Authentication state (user, token, permissions)
- Real-time connection status
- Any state that needs to be shared between Effect services and React components
For complete React integration patterns, see the effect.ts-react skill.
Dependency Injection Patterns
Pattern 1: Service Access with yield*
const program = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const db = yield* Database.Database
const config = yield* Config.Config
const data = yield* db.query('SELECT * FROM users')
return data
})
Pattern 2: Creating Layers
Layer.succeed: Services with No Dependencies
const ConfigLayer = Layer.succeed(
Config.Config,
{ apiKey: process.env.API_KEY }
)
const MockHttpClientLayer = Layer.succeed(
HttpClient.HttpClient,
createMockHttpClient()
)
Layer.effect: Services with Dependencies
const LoggerLive = Layer.effect(
Logger.Logger,
Effect.gen(function* () {
const config = yield* Config.Config
return {
log: (message: string) => Effect.gen(function* () {
const { logLevel } = yield* config.getConfig
console.log(`[${logLevel}] ${message}`)
})
}
})
)
const result = await Effect.runPromise(
program.pipe(
Effect.provide(LoggerLive),
Effect.provide(ConfigLayer)
)
)
Pattern 3: Merging vs Composing Layers
const IndependentLayers = Layer.mergeAll(
HttpClientLayer,
ConfigLayer
)
const DependentLayer = LoggerLive.pipe(
Layer.provide(ConfigLayer)
)
const AppLayer = DatabaseLive.pipe(
Layer.provide(LoggerLive),
Layer.provide(ConfigLayer),
Layer.provideMerge(HttpClientLayer)
)
Pattern 4: Conditional Dependencies
const getAppLayer = () => {
if (process.env.NODE_ENV === 'test') {
return Layer.mergeAll(MockHttpClientLayer, MockDatabaseLayer)
}
return Layer.mergeAll(FetchHttpClient.layer, RealDatabaseLayer)
}
Pattern 5: Layer Error Handling
Layers can fail during construction. Handle errors at the layer level:
Layer.catchAll: Recover from Errors
const ServerLayer = Layer.effect(
HTTPServer,
Effect.gen(function* () {
const host = yield* Config.string("HOST")
console.log(`Listening on http://localhost:${host}`)
})
).pipe(
Layer.catchAll((error) => {
return Layer.effect(
HTTPServer,
Effect.gen(function* () {
console.log("Listening on http://localhost:3000")
})
)
})
)
Layer.orElse: Fallback to Alternative Layer
const DatabaseLayer = PostgresDatabaseLayer.pipe(
Layer.orElse(() => InMemoryDatabaseLayer)
)
Layer.tap: Side Effects During Acquisition
const ServerLayer = Layer.effect(HTTPServer, ...).pipe(
Layer.tap((ctx) =>
Console.log("Server layer acquired successfully")
),
Layer.tapError((err) =>
Console.log(`Server layer failed: ${err}`)
)
)
Why: Layer error handling:
- Allows graceful degradation (fallback layers)
- Enables logging/monitoring during layer construction
- Provides type-safe error recovery
Error Handling Patterns
Pattern 1: Explicit Error Types
const program: Effect.Effect<
Data,
HttpClientError.HttpClientError | DatabaseError | ValidationError,
HttpClient.HttpClient
> = ...
Pattern 2: Error Recovery
program.pipe(
Effect.catchTag('HttpClientError', (error) => {
return Effect.succeed(defaultValue)
}),
Effect.catchAll((error) => {
return Effect.fail(error)
})
)
Pattern 3: Retry Logic
import { Schedule } from 'effect'
program.pipe(
Effect.retry({
schedule: Schedule.exponential(1000),
times: 3,
})
)
program.pipe(
Effect.retry({
schedule: Schedule.recurs(5),
while: (error) => error instanceof HttpClientError.RequestError,
})
)
Pattern 4: Timeout
program.pipe(
Effect.timeout('10 seconds'),
Effect.timeoutTo({
duration: '5 seconds',
onTimeout: () => Effect.fail(new TimeoutError()),
})
)
Composition Patterns
Pattern 1: Sequential Composition
const program = Effect.gen(function* () {
const user = yield* getUser(userId)
const profile = yield* getProfile(user.id)
const settings = yield* getSettings(profile.id)
return { user, profile, settings }
})
Pattern 2: Parallel Composition
import { Effect } from 'effect'
const program = Effect.gen(function* () {
const [user, profile, settings] = yield* Effect.all([
getUser(userId),
getProfile(userId),
getSettings(userId),
])
return { user, profile, settings }
})
Pattern 3: Conditional Composition
const program = Effect.gen(function* () {
const user = yield* getUser(userId)
if (user.isAdmin) {
return yield* getAdminData(user.id)
} else {
return yield* getRegularData(user.id)
}
})
Pattern 4: Effect.all with Options
const program = Effect.all(
[getUser(id1), getUser(id2), getUser(id3)],
{
concurrency: 2,
batching: true,
}
)
Integration Patterns
Pattern 1: Effect → Async/Await Boundary
export async function handler(request: Request) {
const result = await Effect.runPromise(
myEffectProgram.pipe(
Effect.provide(AppLayer)
)
)
return result
}
Pattern 2: Async/Await → Effect Boundary
const program = Effect.tryPromise({
try: () => fetch('https://api.example.com'),
catch: (error) => new HttpClientError.RequestError({
request: {} as any,
reason: 'Transport',
cause: error,
}),
})
Pattern 3: Effect.gen with try/catch
const program = Effect.gen(function* () {
try {
const result = yield* someEffect
return result
} catch (error) {
return yield* Effect.fail(new MyError(error))
}
})
Pattern 4: Layer.launch for Long-Running Services
Convert a Layer to an Effect for long-running services (like HTTP servers):
import { Layer, Effect } from 'effect'
class HTTPServer extends Context.Tag("HTTPServer")<
HTTPServer,
void
>() {}
const ServerLayer = Layer.effect(
HTTPServer,
Effect.gen(function* () {
console.log("Starting HTTP server...")
})
)
Effect.runFork(Layer.launch(ServerLayer))
Use Cases:
- HTTP servers
- Background workers
- Long-running processes
- Services that should run until explicitly stopped
Performance Considerations
1. Avoid Unnecessary Effect Wrapping
const value = yield* Effect.succeed(42)
const value = 42
2. Use Effect.all for Parallel Operations
const [a, b, c] = yield* Effect.all([
fetchA(),
fetchB(),
fetchC(),
])
const a = yield* fetchA()
const b = yield* fetchB()
const c = yield* fetchC()
3. Batch Operations
const users = yield* Effect.all(
userIds.map(id => getUser(id)),
{ batching: true }
)
4. Use Effect.cache for Expensive Operations
import { Effect, Cache } from 'effect'
const cachedProgram = program.pipe(
Effect.cached({ capacity: 100, timeToLive: '1 hour' })
)
Real-World Examples
Example 1: HTTP Client with Retry and Timeout
import { HttpClient, HttpClientError } from '@effect/platform'
import { Effect, pipe, Schedule } from 'effect'
import type { TimeoutException } from 'effect/Cause'
export const getWalletRiskScore = (
address: string,
): Effect.Effect<
unknown,
HttpClientError.HttpClientError | TimeoutException,
HttpClient.HttpClient
> =>
pipe(
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client
.get(fullUrl, { headers })
.pipe(
Effect.timeout('10 seconds'),
Effect.retry({
schedule: Schedule.exponential(1000),
times: 3,
}),
)
if (response.status < 200 || response.status >= 300) {
throw new HttpClientError.ResponseError({
request: {} as any,
response: { ...response, status: response.status } as any,
reason: 'StatusCode' as const,
error: `HTTP ${response.status}`,
} as any)
}
return yield* response.json
}),
Effect.withSpan('getWalletRiskScore', { attributes: { address } }),
)
Key Points:
- Explicit error types in signature
- Retry logic with exponential backoff
- Timeout protection
- OpenTelemetry tracing
- Proper error handling
Example 2: Providing Dependencies
import { FetchHttpClient } from '@effect/platform'
import { Effect } from 'effect'
const result = await Effect.runPromise(
getWalletRiskScore(address).pipe(
Effect.provide(FetchHttpClient.layer)
)
)
const mockLayer = Layer.succeed(HttpClient.HttpClient, mockHttpClient)
const result = await Effect.runPromise(
getWalletRiskScore(address).pipe(
Effect.provide(mockLayer)
)
)
Key Points:
- Same function, different dependencies
- Easy to test (just swap layers)
- Type-safe (TypeScript ensures correct types)
Common Anti-Patterns
Anti-Pattern 1: Creating Dependencies Internally
export async function fetchData() {
const httpClient = HttpClient.make(...)
return await Effect.runPromise(
program.pipe(Effect.provideService(..., httpClient))
)
}
export const fetchData = (): Effect.Effect<Data, Error, HttpClient.HttpClient> =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
})
Anti-Pattern 2: Mixing Async/Await with Effect Internally
export async function processData() {
const result = await Effect.runPromise(effect1)
const processed = await process(result)
return await Effect.runPromise(effect2(processed))
}
export const processData = (): Effect.Effect<Data, Error, R> =>
Effect.gen(function* () {
const result = yield* effect1
const processed = process(result)
return yield* effect2(processed)
})
Anti-Pattern 3: Ignoring Error Types
export const program = Effect.gen(function* () {
try {
return yield* riskyOperation()
} catch (error) {
}
})
export const program: Effect.Effect<Data, MyError, R> =
riskyOperation().pipe(
Effect.catchAll((error) => Effect.fail(new MyError(error)))
)
Anti-Pattern 4: Not Using Layers
function process(userId: string, httpClient: HttpClient, db: Database) {
}
const process = (userId: string): Effect.Effect<Data, Error, HttpClient.HttpClient | Database.Database> =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const db = yield* Database.Database
})
Anti-Pattern 5: Leaking Dependencies in Service Interfaces
class Database extends Context.Tag("Database")<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown, never, Config | Logger>
}
>()
const program: Effect.Effect<Data, Error, Database | Config | Logger> = ...
class Database extends Context.Tag("Database")<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown>
}
>()
const DatabaseLive = Layer.effect(
Database,
Effect.gen(function* () {
const config = yield* Config.Config
const logger = yield* Logger.Logger
return {
query: (sql: string) => Effect.gen(function* () {
yield* logger.log(`Executing: ${sql}`)
return { result: 'data' }
})
}
})
)
const program: Effect.Effect<Data, Error, Database> = ...
Best Practices Summary
-
Write Pure Effect Functions
- Return
Effect.Effect<A, E, R>, not Promise<A>
- Dependencies in type signature (
R)
- Errors in type signature (
E)
-
Avoid Requirement Leakage ⚠️ CRITICAL
- Service interfaces should have
R = never
- Dependencies managed at Layer construction level
- Keeps services testable and composable
-
Use Layers for Dependency Injection
Layer.succeed for services with no dependencies
Layer.effect for services that depend on other services
- Compose layers to build dependency graph
- Use
Layer.merge for concurrent combination
- Use
Layer.provide for sequential composition
-
Layer Naming Conventions
Live suffix for production implementations (e.g., DatabaseLive)
Test suffix for test implementations (e.g., DatabaseTest)
-
Explicit, verbose error types on the Effect
- Specify the error union inline on the Effect (e.g.
Effect<A, FooError | BarError, R>), not as a separate type; avoid a single generic Error.
- Put errors in type signature; use Effect combinators (catchAll, retry, timeout).
- Use Layer error handling (catchAll, orElse, tap) for layer construction.
- Don't use try/catch for Effect errors.
-
Requirement type: Tag, not typeof Tag
- In
Effect.Effect<A, E, R>, use the Tag type (e.g. UsersServiceTag) in R, not typeof UsersServiceTag.
-
Compose Effects, Don't Mix Paradigms
- Use Effect.gen for sequential composition
- Use Effect.all for parallel composition
- Convert at boundaries (Effect ↔ Promise)
-
Use Built-in Layers
FetchHttpClient.layer for HTTP
- Don't create HttpClient manually
- Leverage Effect's platform packages
-
Performance
- Use Effect.all for parallel operations
- Avoid unnecessary Effect wrapping
- Cache expensive computations
Migration Guide
Step 1: Identify Dependencies
function fetchData(httpClient: HttpClient, config: Config) {
}
const fetchData = (): Effect.Effect<Data, Error, HttpClient.HttpClient | Config.Config> =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const config = yield* Config.Config
})
Step 2: Convert Async Functions
export async function processData() {
const data = await fetchData()
return process(data)
}
export const processData = (): Effect.Effect<Data, Error, R> =>
Effect.gen(function* () {
const data = yield* fetchData()
return process(data)
})
Step 3: Create Layers
const ConfigLayer = Layer.succeed(Config.Config, config)
const HttpClientLayer = FetchHttpClient.layer
const LoggerLayer = Layer.effect(
Logger.Logger,
Effect.gen(function* () {
const config = yield* Config.Config
return {
log: (message: string) => console.log(`[${config.logLevel}] ${message}`)
}
})
)
const AppLayer = LoggerLayer.pipe(
Layer.provide(ConfigLayer),
Layer.provideMerge(HttpClientLayer)
)
Step 4: Provide at Boundaries
const result = await Effect.runPromise(
processData().pipe(Effect.provide(AppLayer))
)
Advanced Patterns
Effect.Service API (Alternative Approach)
Effect.ts 3.9.0+ introduced Effect.Service which simplifies service definitions by combining Tag and Layer creation:
import { Effect } from 'effect'
import { FileSystem } from '@effect/platform'
import { NodeFileSystem } from '@effect/platform-node'
class Cache extends Effect.Service<Cache>()("app/Cache", {
effect: Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
return {
lookup: (key: string) => fs.readFileString(`cache/${key}`)
}
}),
dependencies: [NodeFileSystem.layer]
}) {}
const program = Effect.gen(function* () {
const cache = yield* Cache
const data = yield* cache.lookup("my-key")
})
const runnable = program.pipe(Effect.provide(Cache.Default))
When to Use:
Effect.Service: Application code with clear runtime implementation
Context.Tag: Library code or dynamically-scoped values
Note: This skill focuses on the foundational Context.Tag approach, which provides more explicit control and is universally applicable. Effect.Service is syntactic sugar that can simplify common cases.
References
Related Skills
effect.ts-fundamentals - Effect as value, pipe/flatMap, FP data types, Schema
effect.ts-react - NEW: Integrating Effect.ts with React (reactive stores, hooks, forms)
effect.ts-testing - Testing Effect.ts code
typescript-expert - TypeScript best practices
error-handling-patterns - Error handling strategies
Remember: Effect.ts is designed for explicit dependency management and type-safe composition. Embrace its patterns rather than fighting them. Write pure Effect functions, use Layers for dependencies, avoid requirement leakage, and compose at boundaries. For React applications, use reactive stores to bridge Effect services and React components (see effect.ts-react skill).