
Effect Ts
- 82 installs
- 38 repo stars
- Updated May 20, 2026
- joelhooks/effectts-skills
Guidance for writing idiomatic Effect v4 TypeScript - services, layers, error handling, schema, testing, and HTTP.
About
Covers Effect v4 patterns for ServiceMap services, layers, tagged errors, schema data modeling, and testing, referencing the Effect source. A developer uses it when writing or refactoring Effect-TS code.
- Source-first rule: reference the Effect repo mirror before writing code
- Covers services, layers, Schema, tagged errors, HTTP/CLI, and @effect/vitest testing
Effect Ts by the numbers
- 82 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,041 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joelhooks/effectts-skills --skill effect-tsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 82 |
|---|---|
| repo stars | ★ 38 |
| Last updated | May 20, 2026 |
| Repository | joelhooks/effectts-skills ↗ |
What it does
Guidance for writing idiomatic Effect v4 TypeScript - services, layers, error handling, schema, testing, and HTTP.
Files
Effect-TS (v4)
Patterns from effect-solutions and the Effect source. This covers the latest v4 APIs.
Source-First Rule
When working in any repo that uses Effect (effect or @effect/* in package/dependency files), reference the official Effect source before writing, reviewing, or refactoring Effect code. Do not rely on stale memory, blog posts, or high-level docs alone.
- If the
effect_sourcetool is available, use it forstatus,hydrate, andsearchinstead of hand-rolled shell commands. - First check for a repo-local shallow source mirror at
.agent-sources/effect/. - If it is missing, create it before doing Effect work:
mkdir -p .agent-sources && git clone --depth 1 --filter=blob:none https://github.com/effect-ts/effect.git .agent-sources/effect
- Keep the mirror out of product commits. If needed, add
.agent-sources/to.git/info/exclude, not the project.gitignore, unless Joel explicitly wants it committed. - Search the mirror for current patterns and APIs, especially under
packages/effect/src/and package tests/examples, before calling something an Effect best practice.
Local Source References
- repo-local Effect source mirror (canonical for current work):
.agent-sources/effect/ - effect-solutions (best practices, docs, examples):
~/Code/kitlangton/effect-solutions/ - fallback global Effect monorepo:
~/Code/effect-ts/effect/ - Search source for implementations:
grep -r "pattern" .agent-sources/effect/packages/effect/src/
Effect.gen and Effect.fn
Effect.gen provides sequential, readable composition (like async/await for Effect):
import { Effect } from "effect"
const program = Effect.gen(function* () {
const data = yield* fetchData
yield* Effect.logInfo(`Processing: ${data}`)
return yield* processData(data)
})Effect.fn adds call-site tracing and named spans. Use for all service methods:
const processUser = Effect.fn("processUser")(function* (userId: string) {
yield* Effect.logInfo(`Processing user ${userId}`)
const user = yield* getUser(userId)
return yield* processData(user)
})
// Second argument for cross-cutting concerns (retry, timeout)
const fetchWithRetry = Effect.fn("fetchWithRetry")(
function* (url: string) {
const data = yield* fetchData(url)
return yield* processData(data)
},
flow(
Effect.retry(Schedule.recurs(3)),
Effect.timeout("5 seconds")
)
)ServiceMap.Service
Define services as classes with a unique tag and typed interface:
import { Effect, ServiceMap } from "effect"
class Database extends ServiceMap.Service<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown[]>
readonly execute: (sql: string) => Effect.Effect<void>
}
>()("@app/Database") {}Implement with Layer.effect or Layer.sync, using Effect.fn for all methods:
import { Effect, Layer } from "effect"
class Users extends ServiceMap.Service<
Users,
{
readonly findById: (id: UserId) => Effect.Effect<User, UserNotFoundError>
readonly all: () => Effect.Effect<readonly User[]>
}
>()("@app/Users") {
static readonly layer = Layer.effect(
Users,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const findById = Effect.fn("Users.findById")(function* (id: UserId) {
const response = yield* http.get(`/users/${id}`)
return yield* HttpClientResponse.schemaBodyJson(User)(response)
})
const all = Effect.fn("Users.all")(function* () {
const response = yield* http.get("/users")
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(User))(response)
})
return { findById, all }
})
)
}Rules:
- Tag identifiers must be unique. Use
@app/ServiceNamepattern - Service methods should have
R = never(dependencies via Layer, not method signatures) - Use
readonlyproperties
See references/services-and-layers.md for service-driven development, test layers, layer memoization, and full composition patterns.
Schema.Class and Branded Types
Use Schema.Class for domain records. Brand all entity IDs and domain primitives:
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
const Email = Schema.String.pipe(Schema.brand("Email"))
type Email = typeof Email.Type
class User extends Schema.Class("User")({
id: UserId,
name: Schema.String,
email: Email,
createdAt: Schema.Date,
}) {
get displayName() { return `${this.name} (${this.email})` }
}
// Construct with makeUnsafe for brands
const userId = UserId.makeUnsafe("user-123")Use Schema.TaggedClass + Schema.Union for variants (OR types):
import { Match, Schema } from "effect"
class Success extends Schema.TaggedClass("Success")("Success", {
value: Schema.Number,
}) {}
class Failure extends Schema.TaggedClass("Failure")("Failure", {
error: Schema.String,
}) {}
const Result = Schema.Union([Success, Failure])
type Result = typeof Result.Type
// Exhaustive pattern matching
const render = (r: Result) => Match.valueTags(r, {
Success: ({ value }) => `Got: ${value}`,
Failure: ({ error }) => `Error: ${error}`,
})See references/data-modeling.md for JSON encoding, Schema.Literals, validation, and full patterns.
Schema.TaggedErrorClass
Define domain errors with Schema.TaggedErrorClass. They are yieldable (no Effect.fail needed):
import { Schema } from "effect"
class UserNotFoundError extends Schema.TaggedErrorClass("UserNotFoundError")(
"UserNotFoundError",
{ userId: UserId, message: Schema.String }
) {}
// Yieldable: yield directly in generators
const getUser = Effect.fn("getUser")(function* (id: UserId) {
const user = yield* findUser(id)
if (!user) yield* new UserNotFoundError({ userId: id, message: "Not found" })
return user
})Recover with catchTag / catchTags:
// Single tag
const recovered = program.pipe(
Effect.catchTag("UserNotFoundError", (e) =>
Effect.succeed(`User ${e.userId} missing`)
)
)
// Multiple tags
const recovered2 = program.pipe(
Effect.catchTags({
UserNotFoundError: (e) => Effect.succeed("not found"),
ValidationError: (e) => Effect.succeed("invalid"),
})
)See references/error-handling.md for defects, Schema.Defect, and recovery patterns.
Layer Composition
Compose layers with Layer.provideMerge (incremental, flat types) and Layer.merge (parallel):
import { Effect, Layer } from "effect"
// Compose layers for the app
const appLayer = UserService.layer.pipe(
Layer.provideMerge(DatabaseLayer),
Layer.provideMerge(LoggerLayer),
Layer.provideMerge(ConfigLayer),
)
// Provide once at the entry point
const main = program.pipe(Effect.provide(appLayer))
Effect.runPromise(main)Key rules:
- Store parameterized layers in constants (layer memoization by reference identity)
- Provide once at app entry, not scattered throughout code
- Use
Layer.syncfor synchronous implementations,Layer.effectfor effectful ones
Testing Quick Start
import { describe, expect, it } from "@effect/vitest"
import { Effect, Layer } from "effect"
it.effect("queries database", () =>
Effect.gen(function* () {
const db = yield* Database
const results = yield* db.query("SELECT *")
expect(results.length).toBe(2)
}).pipe(Effect.provide(Database.testLayer))
)- Use
it.effectfor Effect-based tests (provides TestContext with TestClock) - Use
it.livefor real time / real clock - Provide fresh layers per test to prevent state leakage
- Use
it.layeronly when sharing expensive resources across a suite
See references/testing.md for the full worked example and advanced patterns.
Pipe for Instrumentation
const program = fetchData.pipe(
Effect.timeout("5 seconds"),
Effect.retry(Schedule.exponential("100 millis").pipe(
Schedule.compose(Schedule.recurs(3))
)),
Effect.tap((data) => Effect.logInfo(`Fetched: ${data}`)),
Effect.withSpan("fetchData"),
)Anti-Patterns
| Do Not | Do Instead |
|---|---|
console.log(...) | Effect.log(...) with structured data |
process.env.KEY | Config.string("KEY") or Config.redacted("KEY") |
throw new Error() inside Effect.gen | yield* new TaggedError({...}) or Effect.fail(...) |
Effect.runSync(...) inside services | Keep everything effectful |
Effect.catchAll(() => ...) losing type info | Effect.catchTag / Effect.catchTags |
null / undefined in domain types | Option<T> with Option.match |
Option.getOrThrow(...) | Option.match({ onNone, onSome }) or Option.getOrElse |
Effect.Service (v3) | ServiceMap.Service (v4) |
Schema.TaggedError<T>() (v3) | Schema.TaggedErrorClass("Tag")("Tag", {...}) (v4) |
Scatter Effect.provide calls | Provide once at app entry |
| Call parameterized layer constructors inline | Store layers in constants (memoization) |
Reference Files
Load these as needed for deeper patterns:
- [Services & Layers](references/services-and-layers.md): ServiceMap.Service, service-driven development, test layers, layer memoization, provide vs provideMerge
- [Data Modeling](references/data-modeling.md): Schema.Class, branded types, variants, Match.valueTags, JSON encoding
- [Schema Decisions](references/schema-decisions.md): Schema.Class vs Struct vs TaggedClass decision flowchart, migration patterns
- [Error Handling](references/error-handling.md): Schema.TaggedErrorClass, catch/catchTag/catchTags, defects, Schema.Defect, TypeId/refail patterns
- [Testing](references/testing.md): @effect/vitest setup, it.effect/it.live/it.layer, TestClock, Effect.flip, FiberRef isolation, worked example
- [HTTP Clients](references/http-clients.md): HttpClient, request building, response decoding, middleware, retries, typed API service
- [CLI](references/cli.md): Command.make, Arguments, Flags, subcommands, worked task manager example
- [Config](references/config.md): Config module, schema validation, ConfigProvider, Redacted, config layers
- [Processes & Scopes](references/processes.md): Fork types, Scope.extend, Command for child processes, killable background tasks
- [Setup](references/setup.md): tsconfig, Effect Language Service, project structure, module settings
Command-Line Interfaces
Table of Contents
- Installation
- Minimal Example
- Arguments and Flags
- Subcommands
- Worked Example: Task Manager
- Quick Reference
Installation
bun add effect@beta @effect/platform-bun@betaFor Node.js, use @effect/platform-node@beta instead.
Minimal Example
import { Argument, Command, Flag } from "effect/unstable/cli"
import { BunServices, BunRuntime } from "@effect/platform-bun"
import { Console, Effect } from "effect"
const name = Argument.string("name").pipe(Argument.withDefault("World"))
const shout = Flag.boolean("shout").pipe(Flag.withAlias("s"))
const greet = Command.make("greet", { name, shout }, ({ name, shout }) => {
const message = `Hello, ${name}!`
return Console.log(shout ? message.toUpperCase() : message)
})
const cli = Command.run(greet, { name: "greet", version: "1.0.0" })
cli(process.argv).pipe(
Effect.provide(BunServices.layer),
BunRuntime.runMain
)Built-in --help and --version work automatically. Every command should have Command.withDescription for useful help output.
Arguments and Flags
Arguments are positional. Flags are named options. Flags must come before arguments.
Arguments
import { Argument } from "effect/unstable/cli"
Argument.string("file") // required text
Argument.string("output").pipe(Argument.optional) // optional
Argument.string("format").pipe(Argument.withDefault("json")) // default
Argument.string("files").pipe(Argument.variadic()) // zero or more
Argument.string("files").pipe(Argument.atLeast(1)) // one or more
Argument.integer("id").pipe(Argument.withSchema(TaskId)) // schema-validatedFlags
import { Flag } from "effect/unstable/cli"
Flag.boolean("verbose").pipe(Flag.withAlias("v")) // boolean
Flag.string("output").pipe(Flag.withAlias("o")) // text
Flag.string("config").pipe(Flag.optional) // optional text
Flag.choice("format", ["json", "yaml", "toml"]) // enum
Flag.integer("count").pipe(Flag.withDefault(10)) // integer with defaultAdd descriptions for help output:
Argument.withDescription("The task description")
Flag.withDescription("Show all tasks including completed")Subcommands
const add = Command.make("add", { task }, ({ task }) =>
Console.log(`Adding: ${task}`)
).pipe(Command.withDescription("Add a new task"))
const list = Command.make("list", {}, () =>
Console.log("Listing tasks...")
).pipe(Command.withDescription("List all tasks"))
const app = Command.make("tasks").pipe(
Command.withDescription("A simple task manager"),
Command.withSubcommands([add, list])
)Worked Example: Task Manager
Schema
import { Array, Option, Schema } from "effect"
const TaskId = Schema.Number.pipe(Schema.brand("TaskId"))
type TaskId = typeof TaskId.Type
class Task extends Schema.Class("Task")({
id: TaskId,
text: Schema.NonEmptyString,
done: Schema.Boolean,
}) {
toggle() { return new Task({ ...this, done: !this.done }) }
}
class TaskList extends Schema.Class("TaskList")({
tasks: Schema.Array(Task),
}) {
static Json = Schema.fromJsonString(TaskList)
static empty = new TaskList({ tasks: [] })
get nextId(): TaskId {
if (this.tasks.length === 0) return TaskId.makeUnsafe(1)
return TaskId.makeUnsafe(Math.max(...this.tasks.map((t) => t.id)) + 1)
}
add(text: string): [TaskList, Task] {
const task = new Task({ id: this.nextId, text, done: false })
return [new TaskList({ tasks: [...this.tasks, task] }), task]
}
toggle(id: TaskId): [TaskList, Option.Option<Task>] {
const index = this.tasks.findIndex((t) => t.id === id)
if (index === -1) return [this, Option.none()]
const updated = this.tasks[index].toggle()
const tasks = Array.modify(this.tasks, index, () => updated)
return [new TaskList({ tasks }), Option.some(updated)]
}
}Service
import { Effect, FileSystem, Layer, ServiceMap } from "effect"
class TaskRepo extends ServiceMap.Service<TaskRepo, {
readonly list: (all?: boolean) => Effect.Effect<ReadonlyArray<Task>>
readonly add: (text: string) => Effect.Effect<Task>
readonly toggle: (id: TaskId) => Effect.Effect<Option.Option<Task>>
readonly clear: () => Effect.Effect<void>
}>()("TaskRepo") {
static layer = Layer.effect(TaskRepo, Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = "tasks.json"
const load = Effect.gen(function* () {
const content = yield* fs.readFileString(path)
return yield* Schema.decodeEffect(TaskList.Json)(content)
}).pipe(Effect.orElseSucceed(() => TaskList.empty))
const save = (list: TaskList) => Effect.gen(function* () {
const json = yield* Schema.encodeEffect(TaskList.Json)(list)
yield* fs.writeFileString(path, json)
})
return {
list: Effect.fn("TaskRepo.list")(function* (all?: boolean) {
const taskList = yield* load
return all ? taskList.tasks : taskList.tasks.filter((t) => !t.done)
}),
add: Effect.fn("TaskRepo.add")(function* (text: string) {
const list = yield* load
const [newList, task] = list.add(text)
yield* save(newList)
return task
}),
toggle: Effect.fn("TaskRepo.toggle")(function* (id: TaskId) {
const list = yield* load
const [newList, task] = list.toggle(id)
yield* save(newList)
return task
}),
clear: Effect.fn("TaskRepo.clear")(function* () {
yield* save(TaskList.empty)
}),
}
}))
}Commands
import { Argument, Command, Flag } from "effect/unstable/cli"
import { Console, Effect, Option } from "effect"
const addCmd = Command.make("add", {
text: Argument.string("task").pipe(Argument.withDescription("The task description")),
}, ({ text }) =>
Effect.gen(function* () {
const repo = yield* TaskRepo
const task = yield* repo.add(text)
yield* Console.log(`Added task #${task.id}: ${task.text}`)
})
).pipe(Command.withDescription("Add a new task"))
const listCmd = Command.make("list", {
all: Flag.boolean("all").pipe(Flag.withAlias("a"), Flag.withDescription("Include completed")),
}, ({ all }) =>
Effect.gen(function* () {
const repo = yield* TaskRepo
const tasks = yield* repo.list(all)
if (tasks.length === 0) return yield* Console.log("No tasks.")
for (const task of tasks) {
yield* Console.log(`${task.done ? "[x]" : "[ ]"} #${task.id} ${task.text}`)
}
})
).pipe(Command.withDescription("List pending tasks"))
const toggleCmd = Command.make("toggle", {
id: Argument.integer("id").pipe(Argument.withSchema(TaskId)),
}, ({ id }) =>
Effect.gen(function* () {
const repo = yield* TaskRepo
const result = yield* repo.toggle(id)
yield* Option.match(result, {
onNone: () => Console.log(`Task #${id} not found`),
onSome: (task) => Console.log(`Toggled: ${task.text} (${task.done ? "done" : "pending"})`),
})
})
).pipe(Command.withDescription("Toggle a task's done status"))
const clearCmd = Command.make("clear", {}, () =>
Effect.gen(function* () {
yield* (yield* TaskRepo).clear()
yield* Console.log("Cleared all tasks.")
})
).pipe(Command.withDescription("Clear all tasks"))
const app = Command.make("tasks").pipe(
Command.withDescription("A simple task manager"),
Command.withSubcommands([addCmd, listCmd, toggleCmd, clearCmd]),
)Entry point
import { BunServices, BunRuntime } from "@effect/platform-bun"
const cli = Command.run(app, { name: "tasks", version: "1.0.0" })
const mainLayer = Layer.provideMerge(TaskRepo.layer, BunServices.layer)
cli(process.argv).pipe(Effect.provide(mainLayer), BunRuntime.runMain)Version from package.json
import pkg from "./package.json" with { type: "json" }
const cli = Command.run(app, { name: "tasks", version: pkg.version })Requires "resolveJsonModule": true in tsconfig.
Quick Reference
| Concept | API |
|---|---|
| Define command | Command.make(name, config, handler) |
| Positional args | Argument.string, .integer, .optional, .variadic() |
| Named flags | Flag.boolean, .string, .choice, .integer |
| Flag alias | Flag.withAlias("v") |
| Descriptions | Argument.withDescription, Flag.withDescription, Command.withDescription |
| Schema validation | Argument.withSchema(BrandedType) |
| Subcommands | Command.withSubcommands([...]) |
| Run CLI | Command.run(cmd, { name, version }) |
| Bun platform | BunServices.layer + BunRuntime.runMain |
| Node platform | NodeServices.layer + NodeRuntime.runMain |
Config
Table of Contents
- How Config Works
- Basic Usage
- Config Service Pattern
- Config Primitives
- Defaults and Fallbacks
- Validation with Schema
- Config Providers
- Redacted Secrets
How Config Works
By default, Config reads from environment variables. Override with ConfigProvider:
- Production: environment variables (default)
- Tests: in-memory maps or
Layer.succeedwith test values - Development: JSON files or hardcoded values
Basic Usage
import { Config, Effect } from "effect"
const program = Effect.gen(function* () {
const apiKey = yield* Config.redacted("API_KEY")
const port = yield* Config.int("PORT")
console.log(`Starting on port ${port}`)
})Override the provider:
import { ConfigProvider, Layer } from "effect"
const testConfigLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({ API_KEY: "test-key", PORT: "3000" })
)
Effect.runPromise(program.pipe(Effect.provide(testConfigLayer)))Config Service Pattern
Best practice: Create a config service with layer and testLayer:
import { Config, Effect, Layer, Redacted, ServiceMap } from "effect"
class ApiConfig extends ServiceMap.Service<
ApiConfig,
{
readonly apiKey: Redacted.Redacted
readonly baseUrl: string
readonly timeout: number
}
>()("@app/ApiConfig") {
static readonly layer = Layer.effect(
ApiConfig,
Effect.gen(function* () {
const apiKey = yield* Config.redacted("API_KEY")
const baseUrl = yield* Config.string("API_BASE_URL").pipe(
Config.orElse(() => Config.succeed("https://api.example.com"))
)
const timeout = yield* Config.int("API_TIMEOUT").pipe(
Config.orElse(() => Config.succeed(30000))
)
return { apiKey, baseUrl, timeout }
})
)
// Tests: inline values, no ConfigProvider needed
static readonly testLayer = Layer.succeed(ApiConfig, {
apiKey: Redacted.make("test-key"),
baseUrl: "https://test.example.com",
timeout: 5000,
})
}Why this pattern:
- Separates config loading from business logic
- Easy to swap implementations (layer vs testLayer)
- Config errors caught early at layer composition
- Type-safe throughout your app
For tests, just Layer.succeed with hardcoded values. No need for ConfigProvider.fromMap.
Config Primitives
Config.string("MY_VAR") // string
Config.number("PORT") // number
Config.int("MAX_RETRIES") // integer
Config.boolean("DEBUG") // boolean
Config.redacted("API_KEY") // hidden in logs
Config.url("API_URL") // URL
Config.duration("TIMEOUT") // Duration
Config.array(Config.string(), "TAGS") // comma-separated arrayDefaults and Fallbacks
// With orElse
const port = yield* Config.int("PORT").pipe(
Config.orElse(() => Config.succeed(3000))
)
// Optional values (returns Option<string>)
const optionalKey = yield* Config.option(Config.string("OPTIONAL_KEY"))Validation with Schema
Use Config.schema for type-safe validation:
import { Config, Schema } from "effect"
const Port = Schema.NumberFromString.pipe(
Schema.check(Schema.isInt()),
Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
Schema.brand("Port")
)
type Port = typeof Port.Type
const Environment = Schema.Literals(["development", "staging", "production"])
const program = Effect.gen(function* () {
const port = yield* Config.schema(Port, "PORT") // branded Port
const env = yield* Config.schema(Environment, "ENV") // validated enum
})Config Providers
import { ConfigProvider, Layer } from "effect"
// From object
ConfigProvider.layer(ConfigProvider.fromUnknown({ API_KEY: "key", PORT: "3000" }))
// From JSON
ConfigProvider.layer(ConfigProvider.fromJson({ API_KEY: "key", PORT: 8080 }))
// Prefixed env vars (reads APP_API_KEY, APP_PORT, etc.)
ConfigProvider.layer(ConfigProvider.fromEnv().pipe(ConfigProvider.nested("APP")))Redacted Secrets
Always use Config.redacted() for sensitive values:
import { Config, Redacted } from "effect"
const program = Effect.gen(function* () {
const apiKey = yield* Config.redacted("API_KEY")
// Extract value when needed
const headers = { Authorization: `Bearer ${Redacted.value(apiKey)}` }
// Hidden in logs
console.log(apiKey) // Output: <redacted>
})Use Schema.Redacted(Schema.String) in config schemas:
class DatabaseConfig extends ServiceMap.Service<
DatabaseConfig,
{ readonly host: string; readonly port: number; readonly password: Redacted.Redacted }
>()("@app/DatabaseConfig") {
static readonly layer = Layer.effect(DatabaseConfig, Effect.gen(function* () {
const host = yield* Config.schema(Schema.String, "DB_HOST")
const port = yield* Config.schema(Port, "DB_PORT")
const password = yield* Config.schema(Schema.Redacted(Schema.String), "DB_PASSWORD")
return { host, port, password }
}))
}Data Modeling
Table of Contents
- Why Schema
- Records (AND Types)
- Variants (OR Types)
- Branded Types
- JSON Encoding and Decoding
- Common Schema Primitives
Why Schema
- Single source of truth: define once, get TypeScript types + runtime validation + JSON serialization
- Parse safely: validate HTTP/CLI/config data with detailed errors
- Rich domain types: branded primitives prevent confusion, classes add methods
- Ecosystem integration: same schema everywhere (RPC, HttpApi, CLI, frontend, backend)
All representable data composes from two primitives:
- Records (AND): a User has a name AND an email AND a createdAt
- Variants (OR): a Result is a Success OR a Failure
Records (AND Types)
Use Schema.Class for composite data models:
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
class User extends Schema.Class("User")({
id: UserId,
name: Schema.String,
email: Schema.String,
createdAt: Schema.Date,
}) {
get displayName() {
return `${this.name} (${this.email})`
}
}
const user = new User({
id: UserId.makeUnsafe("user-123"),
name: "Alice",
email: "alice@example.com",
createdAt: new Date(),
})Variants (OR Types)
Simple string/number alternatives with Schema.Literals:
const Status = Schema.Literals(["pending", "active", "completed"])
type Status = typeof Status.Type // "pending" | "active" | "completed"Structured variants with Schema.TaggedClass + Schema.Union:
import { Match, Schema } from "effect"
class Success extends Schema.TaggedClass("Success")("Success", {
value: Schema.Number,
}) {}
class Failure extends Schema.TaggedClass("Failure")("Failure", {
error: Schema.String,
}) {}
const Result = Schema.Union([Success, Failure])
type Result = typeof Result.Type
// Exhaustive pattern matching
const renderResult = (result: Result) =>
Match.valueTags(result, {
Success: ({ value }) => `Got: ${value}`,
Failure: ({ error }) => `Error: ${error}`,
})Branded Types
Brand nearly all primitives with semantic meaning. Not just IDs, but emails, URLs, counts, ports, slugs:
import { Schema } from "effect"
// Entity IDs
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
const PostId = Schema.String.pipe(Schema.brand("PostId"))
type PostId = typeof PostId.Type
// Domain primitives
const Email = Schema.String.pipe(Schema.brand("Email"))
type Email = typeof Email.Type
const Port = Schema.Int.pipe(
Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
Schema.brand("Port")
)
type Port = typeof Port.Type
// Usage: impossible to mix types
const userId = UserId.makeUnsafe("user-123")
const postId = PostId.makeUnsafe("post-456")
function getUser(id: UserId) { /* ... */ }
// getUser(postId) // Type error: can't pass PostId where UserId expectedJSON Encoding and Decoding
Use Schema.fromJsonString to combine JSON.parse + schema decoding in one step:
import { Effect, Schema } from "effect"
class Move extends Schema.Class("Move")({
from: Schema.String,
to: Schema.String,
}) {}
const MoveFromJson = Schema.fromJsonString(Move)
const program = Effect.gen(function* () {
// Decode from JSON string
const jsonString = '{"from":"A1","to":"B2"}'
const move = yield* Schema.decodeUnknownEffect(MoveFromJson)(jsonString)
// Encode back to JSON string
const json = yield* Schema.encodeEffect(MoveFromJson)(move)
return json
})Use the FromJson schema (not the base schema) for both decode and encode when working with JSON strings.
Common Schema Primitives
| Schema | TypeScript Type | Notes |
|---|---|---|
Schema.String | string | |
Schema.Number | number | |
Schema.Int | number | Integer validation |
Schema.Boolean | boolean | |
Schema.Date | Date | Parses from ISO string |
Schema.DateTimeUtc | DateTime.Utc | Effect DateTime |
Schema.UUID | string | UUID format validation |
Schema.NonEmptyString | string | Min length 1 |
Schema.NullOr(S) | `T \ | null` |
Schema.Array(S) | readonly T[] | Array of schema |
Schema.Struct({...}) | {...} | Object shape |
Schema.Redacted(S) | Redacted<T> | Hidden in logs |
Schema.Defect | unknown | Wraps unknown errors |
Validation Combinators
// String constraints
Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255))
// Number constraints
Schema.Number.pipe(
Schema.check(Schema.isInt()),
Schema.check(Schema.isBetween({ minimum: 1, maximum: 100 }))
)
// Pattern matching
Schema.String.pipe(Schema.pattern(/^[a-z]+$/))
// Optional fields
Schema.Struct({
name: Schema.String,
bio: Schema.optional(Schema.String),
})Error Handling
Table of Contents
- Schema.TaggedErrorClass
- Yieldable Errors
- Recovering from Errors
- Expected Errors vs Defects
- Schema.Defect for Unknown Errors
Schema.TaggedErrorClass
Define domain errors with Schema.TaggedErrorClass:
import { Schema } from "effect"
class ValidationError extends Schema.TaggedErrorClass("ValidationError")(
"ValidationError",
{
field: Schema.String,
message: Schema.String,
}
) {}
class NotFoundError extends Schema.TaggedErrorClass("NotFoundError")(
"NotFoundError",
{
resource: Schema.String,
id: Schema.String,
}
) {}
const AppError = Schema.Union([ValidationError, NotFoundError])
type AppError = typeof AppError.TypeBenefits:
- Serializable (can send over network, save to DB)
- Type-safe with built-in
_tagfor pattern matching - Custom methods via class extension
- Sensible default
messagewhen you don't declare one
Every distinct failure reason deserves its own error type. Don't collapse multiple failure modes into generic errors like NotFoundError. Use UserNotFoundError, ChannelNotFoundError, etc. with relevant context fields.
Yieldable Errors
Schema.TaggedErrorClass values are yieldable. Return them directly in generators without wrapping in Effect.fail:
import { Effect, Random, Schema } from "effect"
class BadLuck extends Schema.TaggedErrorClass("BadLuck")(
"BadLuck",
{ roll: Schema.Number }
) {}
const rollDie = Effect.gen(function* () {
const roll = yield* Random.nextIntBetween(1, 6)
if (roll === 1) {
yield* new BadLuck({ roll }) // no Effect.fail needed
}
return { roll }
})Recovering from Errors
catch
Handle all errors with a fallback:
const recovered: Effect.Effect<string, never> = program.pipe(
Effect.catch((error) =>
Effect.gen(function* () {
yield* Effect.logError("Error occurred", error)
return `Recovered from ${error.name}`
})
)
)catchTag
Handle a specific error by its _tag:
const recovered = program.pipe(
Effect.catchTag("HttpError", (error) =>
Effect.gen(function* () {
yield* Effect.logWarning(`HTTP ${error.statusCode}: ${error.message}`)
return "Recovered from HttpError"
})
)
)
// HttpError is removed from the error channel; other errors remaincatchTags
Handle multiple error types at once:
const recovered = program.pipe(
Effect.catchTags({
HttpError: () => Effect.succeed("Recovered from HttpError"),
ValidationError: () => Effect.succeed("Recovered from ValidationError"),
})
)
// Both error types removed from the error channelExpected Errors vs Defects
Effect tracks errors in the type system (Effect<A, E, R>) so callers know what can fail and can recover.
Use typed errors for domain failures the caller can handle: validation errors, "not found", permission denied, rate limits.
Use defects for unrecoverable situations: bugs, invariant violations. Defects terminate the fiber and you handle them once at the system boundary (logging, crash reporting).
// At app entry: if config fails, nothing can proceed
const main = Effect.gen(function* () {
const config = yield* loadConfig.pipe(Effect.orDie)
yield* Effect.log(`Starting on port ${config.port}`)
})When to catch defects: Almost never. Only at system boundaries for logging/diagnostics. Use Effect.exit to inspect or Effect.catchAllDefect if you must recover (e.g., plugin sandboxing).
Schema.Defect for Unknown Errors
Wrap unknown errors from external libraries with Schema.Defect:
import { Schema, Effect } from "effect"
class ApiError extends Schema.TaggedErrorClass("ApiError")(
"ApiError",
{
endpoint: Schema.String,
statusCode: Schema.Number,
error: Schema.Defect, // wraps the underlying error
}
) {}
const fetchUser = (id: string) =>
Effect.tryPromise({
try: () => fetch(`/api/users/${id}`).then((r) => r.json()),
catch: (error) => new ApiError({
endpoint: `/api/users/${id}`,
statusCode: 500,
error,
}),
})Schema.Defect handles:
- JavaScript
Errorinstances become{ name, message }objects - Any unknown value becomes a string representation
- Result is serializable for network/storage
Use for: wrapping external library errors, network boundaries, persisting errors to DB, logging systems.
Advanced Patterns
TypeId Branding (from Effect core packages)
Brand error families with a TypeId symbol for runtime type discrimination across package boundaries:
import { hasProperty, isTagged } from "effect/Predicate"
import { Schema } from "effect"
export const TypeId: unique symbol = Symbol.for("@myapp/AppError")
export type TypeId = typeof TypeId
export class NotFoundError extends Schema.TaggedErrorClass("NotFoundError")(
"NotFoundError",
{ resource: Schema.String, id: Schema.String }
) {
readonly [TypeId] = TypeId
static is(u: unknown): u is NotFoundError {
return hasProperty(u, TypeId) && isTagged(u, "NotFoundError")
}
}Static refail Helper (from @effect/cluster)
Create a static method that maps any error into your domain error:
import { Cause, Effect, Schema } from "effect"
class PersistenceError extends Schema.TaggedErrorClass("PersistenceError")(
"PersistenceError",
{ cause: Schema.Defect }
) {
static refail<A, E, R>(
effect: Effect.Effect<A, E, R>
): Effect.Effect<A, PersistenceError, R> {
return Effect.catchAllCause(effect, (cause) =>
Effect.fail(new PersistenceError({ cause: Cause.squash(cause) }))
)
}
}
// Usage: wrap any database call
const safeQuery = PersistenceError.refail(rawDbCall)Effect.flip (Swap Success/Error for Testing)
it.effect("should fail on invalid input", () =>
Effect.gen(function* () {
const service = yield* MyService
const error = yield* service.doThing(badInput).pipe(Effect.flip)
expect(error._tag).toBe("ValidationError")
}).pipe(Effect.provide(TestLayer))
)Patterns adapted from artimath/effect-skills (MIT).
HTTP Clients
Table of Contents
- Minimal Example
- Building Requests
- Response Decoding
- Client Middleware
- Error Handling
- Retries
- Worked Example: Typed API Service
- Quick Reference
Minimal Example
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Effect, Schema } from "effect"
const Repo = Schema.Struct({
id: Schema.Number,
name: Schema.String,
full_name: Schema.String,
stargazers_count: Schema.Number,
})
const program = Effect.gen(function* () {
const response = yield* HttpClient.get("https://api.github.com/repos/Effect-TS/effect")
const repo = yield* HttpClientResponse.schemaBodyJson(Repo)(response)
console.log(`${repo.full_name}: ${repo.stargazers_count} stars`)
})
program.pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise)HttpClient.getreturns an Effect requiringHttpClientin contextHttpClientResponse.schemaBodyJsondecodes and validates the JSON bodyFetchHttpClient.layerprovides the implementation usingfetch
Building Requests
Headers
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
const request = HttpClientRequest.get("https://api.github.com/repos/Effect-TS/effect").pipe(
HttpClientRequest.setHeader("Accept", "application/vnd.github.v3+json"),
HttpClientRequest.bearerToken("ghp_xxxx"),
)
const response = yield* HttpClient.execute(request)Helpers: setHeader, setHeaders, bearerToken, basicAuth, acceptJson.
Query Parameters
const request = HttpClientRequest.get("https://api.github.com/search/repositories").pipe(
HttpClientRequest.setUrlParam("q", "effect language:typescript"),
HttpClientRequest.setUrlParam("sort", "stars"),
)Request Body
Use HttpClientRequest.schemaBodyJson (returns an Effect because encoding can fail):
const CreateIssue = Schema.Struct({ title: Schema.String, body: Schema.String })
const request = yield* HttpClientRequest.post(`https://api.github.com/repos/${owner}/${repo}/issues`).pipe(
HttpClientRequest.schemaBodyJson(CreateIssue)({ title: "Bug", body: "Description" })
)
const response = yield* HttpClient.execute(request)Response Decoding
Schema-validated JSON body
const response = yield* HttpClient.get("https://api.github.com/users/effect-ts")
const user = yield* HttpClientResponse.schemaBodyJson(User)(response)Status code matching
const result = yield* HttpClientResponse.matchStatus(response, {
"2xx": HttpClientResponse.schemaBodyJson(User),
404: () => Effect.fail(new UserNotFound(username)),
orElse: (r) => Effect.fail(new Error(`Unexpected: ${r.status}`)),
})Filter 2xx only
yield* HttpClientResponse.filterStatusOk(response) // fails on non-2xx
const user = yield* HttpClientResponse.schemaBodyJson(User)(response)Client Middleware
Use HttpClient.mapRequest for transformations applied to all requests:
import { flow } from "effect"
const GitHubClient = Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const baseClient = yield* HttpClient.HttpClient
return baseClient.pipe(
HttpClient.mapRequest(
flow(
HttpClientRequest.prependUrl("https://api.github.com"),
HttpClientRequest.bearerToken("ghp_xxxx"),
HttpClientRequest.setHeader("Accept", "application/vnd.github.v3+json"),
)
)
)
})
).pipe(Layer.provide(FetchHttpClient.layer))Error Handling
const program = Effect.gen(function* () {
const response = yield* HttpClient.get("https://api.example.com/data")
return yield* HttpClientResponse.schemaBodyJson(Data)(response)
}).pipe(
Effect.catchTag("RequestError", (e) =>
Effect.fail(`Network error: ${e.reason}`)
),
Effect.catchTag("ResponseError", (e) =>
Effect.fail(`HTTP ${e.response.status}: ${e.reason}`)
),
)RequestError: network failures, DNS errors, timeoutsResponseError: non-2xx status (withfilterStatusOk) or body parsing failures
Retries
Manual retry with schedule:
const withRetry = program.pipe(
Effect.retry(Schedule.exponential("100 millis").pipe(
Schedule.compose(Schedule.recurs(3))
))
)Built-in transient retry (rate limiting, timeouts, 5xx):
const ResilientClient = Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return client.pipe(HttpClient.retryTransient({ times: 3 }))
})
).pipe(Layer.provide(FetchHttpClient.layer))Worked Example: Typed API Service
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Effect, Layer, Schema, ServiceMap } from "effect"
const UserId = Schema.Number.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
class User extends Schema.Class("User")({
id: UserId,
login: Schema.String,
name: Schema.NullOr(Schema.String),
public_repos: Schema.Number,
}) {}
class Repo extends Schema.Class("Repo")({
id: Schema.Number,
name: Schema.String,
full_name: Schema.String,
stargazers_count: Schema.Number,
}) {}
class GitHubApi extends ServiceMap.Service<
GitHubApi,
{
readonly getUser: (username: string) => Effect.Effect<User>
readonly getRepo: (owner: string, repo: string) => Effect.Effect<Repo>
readonly listRepos: (username: string) => Effect.Effect<ReadonlyArray<Repo>>
}
>()("GitHubApi") {
static layer = Layer.effect(
GitHubApi,
Effect.gen(function* () {
const baseClient = yield* HttpClient.HttpClient
const client = baseClient.pipe(
HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.github.com"))
)
const getUser = Effect.fn("GitHubApi.getUser")(function* (username: string) {
const response = yield* client.get(`/users/${username}`)
return yield* HttpClientResponse.schemaBodyJson(User)(response)
})
const getRepo = Effect.fn("GitHubApi.getRepo")(function* (owner: string, repo: string) {
const response = yield* client.get(`/repos/${owner}/${repo}`)
return yield* HttpClientResponse.schemaBodyJson(Repo)(response)
})
const listRepos = Effect.fn("GitHubApi.listRepos")(function* (username: string) {
const response = yield* client.get(`/users/${username}/repos`)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Repo))(response)
})
return { getUser, getRepo, listRepos }
})
)
static live = GitHubApi.layer.pipe(Layer.provide(FetchHttpClient.layer))
}
// Usage
const program = Effect.gen(function* () {
const github = yield* GitHubApi
const user = yield* github.getUser("effect-ts")
const repo = yield* github.getRepo("Effect-TS", "effect")
console.log(`${user.login}: ${user.public_repos} repos`)
console.log(`${repo.full_name}: ${repo.stargazers_count} stars`)
})
program.pipe(Effect.provide(GitHubApi.live), Effect.runPromise)Quick Reference
| Concept | API |
|---|---|
| Simple GET | HttpClient.get(url) |
| Execute request | HttpClient.execute(request) |
| Build request | HttpClientRequest.get, .post, .put, .patch, .del |
| Set headers | HttpClientRequest.setHeader, .bearerToken, .basicAuth |
| Query params | HttpClientRequest.setUrlParam, .setUrlParams |
| JSON body | HttpClientRequest.schemaBodyJson(Schema)(data) |
| Decode response | HttpClientResponse.schemaBodyJson(Schema)(response) |
| Status matching | HttpClientResponse.matchStatus(response, { ... }) |
| Filter 2xx | HttpClientResponse.filterStatusOk(response) |
| Base URL | HttpClient.mapRequest(HttpClientRequest.prependUrl(url)) |
| Retry transient | HttpClient.retryTransient({ times: 3 }) |
| Provide client | Effect.provide(FetchHttpClient.layer) |
Process and Scope Management
Adapted from artimath/effect-skills (MIT), updated for Effect v4.
Table of Contents
Fork Types
| Type | Lifetime | Cleanup | Use Case |
|---|---|---|---|
Effect.fork | Dies with parent fiber | Automatic | Concurrent work within a scope |
Effect.forkScoped | Dies with scope | Auto-registered | Server workers, scoped tasks |
Effect.forkDaemon | Independent | Manual required | Background tasks outliving parent |
Effect.forkChild | Dies with parent | Automatic | Child tasks for TestClock |
import { Effect, Fiber, Scope } from "effect"
// fork: dies with parent
const fiber = yield* Effect.fork(myEffect)
const result = yield* Fiber.join(fiber)
// forkScoped: dies when scope closes
yield* Effect.forkScoped(backgroundLoop)
// forkDaemon: outlives parent, YOU must clean up
const fiber = yield* Effect.forkDaemon(work)
yield* scope.addFinalizer(() => Fiber.interrupt(fiber))Scope Patterns
Automatic (Short-Lived)
Effect.scoped creates and closes the scope around the effect:
const output = yield* Effect.scoped(
Effect.gen(function* () {
const process = yield* startProcess(cmd)
return yield* collectOutput(process.stdout)
})
)
// Process auto-killed when scope exitsManual (Long-Lived, Killable)
Use Scope.make() for external lifetime control:
import { Effect, Scope, Exit } from "effect"
// Create a scope WE control
const scope = yield* Scope.make()
// Start resource in OUR scope
const process = yield* startProcess(cmd).pipe(Scope.extend(scope))
// Later, to tear down:
yield* Scope.close(scope, Exit.void)acquireRelease
For resources needing setup and cleanup:
const connection = yield* Effect.acquireRelease(
openConnection(), // acquire
(conn) => closeConnection(conn).pipe(Effect.orDie) // release
)Command (Child Processes)
Use @effect/platform Command service instead of raw child_process:
import { Command } from "@effect/platform"
import { Effect, Stream, Chunk } from "effect"
const runCommand = Effect.gen(function* () {
const cmd = Command.make("git", "status")
const proc = yield* Command.start(cmd)
// Read stdout as text
const outputChunks = yield* proc.stdout.pipe(
Stream.decodeText(),
Stream.runCollect,
)
const output = Chunk.toReadonlyArray(outputChunks).join("")
// Wait for exit
const exitCode = yield* proc.exitCode
return { exitCode, output }
}).pipe(Effect.scoped) // auto-cleanupWriting to stdin
const runWithInput = (command: string, input: string) =>
Effect.gen(function* () {
const cmd = Command.make("bash", "-c", command)
const proc = yield* Command.start(cmd)
// Write to stdin
yield* Stream.make(new TextEncoder().encode(input)).pipe(
Stream.run(proc.stdin)
)
const output = yield* proc.stdout.pipe(
Stream.decodeText(),
Stream.runCollect,
)
return {
exitCode: yield* proc.exitCode,
output: Chunk.toReadonlyArray(output).join(""),
}
}).pipe(Effect.scoped)Process interface
interface Process {
readonly pid: ProcessId
readonly exitCode: Effect<ExitCode> // waits for completion
readonly isRunning: Effect<boolean>
readonly kill: (signal?: Signal) => Effect<void>
readonly stdout: Stream<Uint8Array>
readonly stderr: Stream<Uint8Array>
readonly stdin: Sink<void, Uint8Array>
}Why Command over child_process.spawn:
- Scoped cleanup (process killed on scope close)
- Stream-based stdin/stdout
- Effect error handling
- No manual timeout/cleanup logic
Scope.extend
Ties a resource's lifetime to a specific scope and removes Scope from the effect's requirements:
// Before: Effect<Process, E, CommandExecutor | Scope>
const scoped = Command.start(cmd)
// After: Effect<Process, E, CommandExecutor> (Scope satisfied)
const extended = scoped.pipe(Scope.extend(myScope))Killable Background Process Example
import { Effect, Scope, Exit, Ref, HashMap, Stream, Chunk } from "effect"
import { Command } from "@effect/platform"
interface BackgroundShell {
readonly id: string
readonly process: Process
readonly scope: Scope.CloseableScope
readonly output: string
readonly isComplete: boolean
readonly exitCode?: number
}
const startBackground = (command: string) =>
Effect.gen(function* () {
const id = `shell-${crypto.randomUUID()}`
const scope = yield* Scope.make()
const cmd = Command.make("bash", "-c", command)
const process = yield* Command.start(cmd).pipe(Scope.extend(scope))
// Fork ONLY the output collection (not the scoped acquisition)
yield* Effect.forkDaemon(
Effect.gen(function* () {
const stdout = yield* process.stdout.pipe(
Stream.decodeText(), Stream.runCollect,
)
const exitCode = yield* process.exitCode
// store results...
}).pipe(Effect.ignore)
)
return { id, process, scope }
})
const killShell = (shell: BackgroundShell) =>
Effect.gen(function* () {
if (!shell.isComplete) {
yield* shell.process.kill("SIGTERM")
}
yield* Scope.close(shell.scope, Exit.void)
})Common Anti-Pattern
// BAD: process trapped inside daemon's scope, unreachable from outside
yield* Effect.forkDaemon(
Effect.scoped(
Effect.gen(function* () {
const process = yield* Command.start(cmd) // can't access this!
})
)
)
// GOOD: manual scope, fork only the work
const scope = yield* Scope.make()
const process = yield* Command.start(cmd).pipe(Scope.extend(scope))
yield* Effect.forkDaemon(collectOutput(process)) // fork only collectionSchema Decision Matrix
Adapted from artimath/effect-skills (MIT), updated for Effect v4.
Decision Tree
Is the type used as a key in HashMap/HashSet?
YES -> Schema.Class (implement Equal/Hash)
NO |
v
Does it need computed properties or methods?
YES -> Schema.Class
NO |
v
Is it part of a discriminated union (OR type)?
YES -> Schema.TaggedClass + Schema.Union
NO |
v
Use Schema.StructQuick Reference
| Use Schema.Class when... | Use Schema.Struct when... | Use Schema.TaggedClass when... |
|---|---|---|
| Needs Equal/Hash symbols | Plain DTO, no behavior | Part of a discriminated union |
| Used as HashMap/HashSet key | No identity semantics | Needs automatic _tag field |
| Has computed properties/methods | Decoded and passed around | Pattern matched with Match.valueTags |
| Needs PrimaryKey symbol | Simple config or state | One variant of several options |
Default to Schema.Struct. Most types are DTOs without behavior.
Schema.Struct (Most Common)
For DTOs, config objects, state containers:
import { Schema } from "effect"
const Limits = Schema.Struct({
steps: Schema.Number,
rows: Schema.Number,
bytes: Schema.Number,
})
type Limits = typeof Limits.Type
// Nested
const Capability = Schema.Struct({
issuer: PrincipalId,
holder: PrincipalId,
limits: Limits,
})
type Capability = typeof Capability.Type
// With optional + default
const Config = Schema.Struct({
timeout: Schema.optional(Schema.Number, { default: () => 5000 }),
retries: Schema.optional(Schema.Number, { default: () => 3 }),
})Schema.Class (When Behavior Needed)
Use when the type needs custom equality, hashing, methods, or PrimaryKey:
import { Equal, Hash, Schema } from "effect"
class RunnerAddress extends Schema.Class("RunnerAddress")({
host: Schema.NonEmptyString,
port: Schema.Int,
}) {
[Equal.symbol](that: RunnerAddress): boolean {
return this.host === that.host && this.port === that.port
}
[Hash.symbol]() {
return Hash.cached(this, Hash.string(`${this.host}:${this.port}`))
}
get endpoint(): string {
return `${this.host}:${this.port}`
}
}Schema.TaggedClass (Discriminated Unions)
For union variants with automatic _tag discrimination:
import { Match, Schema } from "effect"
class Appended extends Schema.TaggedClass("Appended")("Appended", {
recordId: RecordId,
}) {}
class AlreadyExists extends Schema.TaggedClass("AlreadyExists")("AlreadyExists", {
recordId: RecordId,
}) {}
class Quarantined extends Schema.TaggedClass("Quarantined")("Quarantined", {
reason: Schema.String,
}) {}
const AppendResult = Schema.Union([Appended, AlreadyExists, Quarantined])
type AppendResult = typeof AppendResult.Type
// Exhaustive match
const handle = (result: AppendResult) =>
Match.valueTags(result, {
Appended: ({ recordId }) => `appended ${recordId}`,
AlreadyExists: ({ recordId }) => `exists ${recordId}`,
Quarantined: ({ reason }) => `quarantined: ${reason}`,
})Branded Types (Always Add Real Constraints)
Don't brand bare Schema.String. Add actual validation:
import { Schema } from "effect"
// BAD: brand without constraints
const UserId = Schema.String.pipe(Schema.brand("UserId"))
// GOOD: brand with real constraints
const UserId = Schema.NonEmptyString.pipe(
Schema.pattern(/^usr_[a-z0-9]+$/),
Schema.brand("UserId")
)
type UserId = typeof UserId.Type
// GOOD: numeric brand with range
const Port = Schema.Int.pipe(
Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
Schema.brand("Port")
)
type Port = typeof Port.TypeMigration Patterns
Interface + Schema to single Schema
// BEFORE (duplicated)
interface VaultEntry { readonly casId: CasId; readonly mediaType: string }
const VaultEntrySchema = Schema.Struct({ casId: CasId, mediaType: Schema.String })
// AFTER (single source of truth)
const VaultEntry = Schema.Struct({ casId: CasId, mediaType: Schema.String })
type VaultEntry = typeof VaultEntry.TypePhantom type to Schema.brand
// BEFORE (compile-time only, no runtime validation)
type WorkflowId = string & { readonly _tag: "WorkflowId" }
// AFTER (runtime validation)
const WorkflowId = Schema.NonEmptyString.pipe(Schema.brand("WorkflowId"))
type WorkflowId = typeof WorkflowId.TypeString literal union to TaggedClass
// BEFORE (no narrowing, no per-variant data)
interface Result { status: "success" | "failure"; data?: unknown; error?: string }
// AFTER (proper discrimination)
class Success extends Schema.TaggedClass("Success")("Success", {
data: Schema.Unknown,
}) {}
class Failure extends Schema.TaggedClass("Failure")("Failure", {
error: Schema.String,
}) {}
const Result = Schema.Union([Success, Failure])
type Result = typeof Result.TypeAnti-Patterns
| Anti-Pattern | Fix |
|---|---|
| Schema.Class for simple DTOs | Use Schema.Struct unless needs behavior |
| String literal union in Struct | Use TaggedClass for variants |
| Separate interface + schema | Single schema as source of truth |
| Schema.Class without Equal/Hash | Use Struct instead (no benefit) |
Phantom & { _tag } | Use Schema.brand with real constraints |
as Type casts | Use Schema.decodeUnknown |
Bare Schema.String.pipe(Schema.brand(...)) | Add real constraints: NonEmptyString, pattern() |
Services & Layers
Table of Contents
- ServiceMap.Service
- Layer Implementations
- Service-Driven Development
- Test Implementations
- Providing Layers
- Layer Memoization
- Sharing Layers Between Tests
ServiceMap.Service
Define services with ServiceMap.Service as a class declaring a unique identifier and typed interface:
import { Effect, ServiceMap } from "effect"
class Database extends ServiceMap.Service<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown[]>
readonly execute: (sql: string) => Effect.Effect<void>
}
>()("@app/Database") {}
class Logger extends ServiceMap.Service<
Logger,
{
readonly log: (message: string) => Effect.Effect<void>
}
>()("@app/Logger") {}Rules:
- Tag identifiers must be unique. Use
@app/ServiceNameor@path/to/ServiceName - Service methods should have no dependencies (
R = never). Dependencies are handled via Layer composition - Use
readonlyproperties
Layer Implementations
Use Layer.effect for effectful implementations and Layer.sync for synchronous ones:
import { Effect, Layer, Schema, ServiceMap } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
class User extends Schema.Class("User")({
id: UserId,
name: Schema.String,
email: Schema.String,
}) {}
class UserNotFoundError extends Schema.TaggedErrorClass("UserNotFoundError")(
"UserNotFoundError",
{ id: UserId }
) {}
class Analytics extends ServiceMap.Service<
Analytics,
{ readonly track: (event: string, data: Record<string, unknown>) => Effect.Effect<void> }
>()("@app/Analytics") {}
class Users extends ServiceMap.Service<
Users,
{
readonly findById: (id: UserId) => Effect.Effect<User, UserNotFoundError>
readonly all: () => Effect.Effect<readonly User[]>
}
>()("@app/Users") {
static readonly layer = Layer.effect(
Users,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const analytics = yield* Analytics
const findById = Effect.fn("Users.findById")(
function* (id: UserId) {
yield* analytics.track("user.find", { id })
const response = yield* http.get(`https://api.example.com/users/${id}`)
return yield* HttpClientResponse.schemaBodyJson(User)(response)
},
Effect.catchTag("ResponseError", (error) =>
error.response.status === 404
? new UserNotFoundError({ id })
: Effect.die(error)
),
)
const all = Effect.fn("Users.all")(function* () {
const response = yield* http.get("https://api.example.com/users")
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(User))(response)
})
return { findById, all }
})
)
}Layer naming: camelCase with descriptive suffix: layer, testLayer, postgresLayer, sqliteLayer.
Service-Driven Development
Sketch leaf service tags first (no implementations). This lets you write and type-check higher-level orchestration before leaf services are runnable:
import { Clock, Effect, Layer, Schema, ServiceMap } from "effect"
const RegistrationId = Schema.String.pipe(Schema.brand("RegistrationId"))
type RegistrationId = typeof RegistrationId.Type
const EventId = Schema.String.pipe(Schema.brand("EventId"))
type EventId = typeof EventId.Type
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
const TicketId = Schema.String.pipe(Schema.brand("TicketId"))
type TicketId = typeof TicketId.Type
class User extends Schema.Class("User")({
id: UserId, name: Schema.String, email: Schema.String,
}) {}
class Registration extends Schema.Class("Registration")({
id: RegistrationId, eventId: EventId, userId: UserId,
ticketId: TicketId, registeredAt: Schema.Date,
}) {}
class Ticket extends Schema.Class("Ticket")({
id: TicketId, eventId: EventId, code: Schema.String,
}) {}
// Leaf services: contracts only, no implementations yet
class Users extends ServiceMap.Service<
Users,
{ readonly findById: (id: UserId) => Effect.Effect<User> }
>()("@app/Users") {}
class Tickets extends ServiceMap.Service<
Tickets,
{ readonly issue: (eventId: EventId, userId: UserId) => Effect.Effect<Ticket> }
>()("@app/Tickets") {}
class Emails extends ServiceMap.Service<
Emails,
{ readonly send: (to: string, subject: string, body: string) => Effect.Effect<void> }
>()("@app/Emails") {}
// Higher-level service: orchestrates leaf services
class Events extends ServiceMap.Service<
Events,
{ readonly register: (eventId: EventId, userId: UserId) => Effect.Effect<Registration> }
>()("@app/Events") {
static readonly layer = Layer.effect(
Events,
Effect.gen(function* () {
const users = yield* Users
const tickets = yield* Tickets
const emails = yield* Emails
const register = Effect.fn("Events.register")(
function* (eventId: EventId, userId: UserId) {
const user = yield* users.findById(userId)
const ticket = yield* tickets.issue(eventId, userId)
const now = yield* Clock.currentTimeMillis
const registration = new Registration({
id: RegistrationId.makeUnsafe(crypto.randomUUID()),
eventId, userId, ticketId: ticket.id,
registeredAt: new Date(now),
})
yield* emails.send(
user.email,
"Event Registration Confirmed",
`Your ticket code: ${ticket.code}`
)
return registration
}
)
return { register }
})
)
}This code compiles and type-checks even though leaf services have no implementations yet. Adding production layers later does not change Events code.
Test Implementations
Use Layer.sync with in-memory state for test layers. Mutable state is fine in tests (JS is single-threaded):
class Database extends ServiceMap.Service<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown[]>
readonly execute: (sql: string) => Effect.Effect<void>
}
>()("@app/Database") {
static readonly testLayer = Layer.sync(Database, () => {
const records: Record<string, unknown> = {
"user-1": { id: "user-1", name: "Alice" },
}
const query = (sql: string) => Effect.succeed(Object.values(records))
const execute = (sql: string) => Console.log(`Test execute: ${sql}`)
return { query, execute }
})
}Providing Layers
Provide once at the app entry point. Do not scatter Effect.provide calls:
// Compose all layers
const appLayer = userServiceLayer.pipe(
Layer.provideMerge(databaseLayer),
Layer.provideMerge(loggerLayer),
Layer.provideMerge(configLayer),
)
// Program uses services freely
const program = Effect.gen(function* () {
const users = yield* UserService
const logger = yield* Logger
yield* logger.info("Starting...")
yield* users.getUser()
})
// Provide once
const main = program.pipe(Effect.provide(appLayer))
Effect.runPromise(main)Why provide once:
- Clear dependency graph in one place
- Easy testing: swap
appLayerfortestLayer - No hidden dependencies
- Simpler refactoring
Layer.provide vs Layer.provideMerge vs Layer.mergeAll
This causes most Effect type errors. Know the difference:
| Method | Deps Satisfied | Available to Program | Use When |
|---|---|---|---|
Layer.provide | Yes | No | Internal layer building (hide implementation detail) |
Layer.provideMerge | Yes | Yes | Tests needing multiple services, incremental composition |
Layer.mergeAll | No | Yes | Combining independent layers at the same level |
// Layer.provide: satisfies deps, hides the provider
const internal = MyService.layer.pipe(Layer.provide(DatabaseLayer))
// Result: Layer<MyService> (Database NOT available to program)
// Layer.provideMerge: satisfies deps AND keeps provider accessible
const test = MyService.layer.pipe(Layer.provideMerge(DatabaseLayer))
// Result: Layer<MyService | Database> (both available)
// Layer.mergeAll: combines without resolving deps
const combined = Layer.mergeAll(UserRepo.layer, OrderRepo.layer)
// Result: Layer<UserRepo | OrderRepo, never, SharedDeps> (deps still required)Common error to recognize:
Effect<A, E, SomeService> is not assignable to Effect<A, E, never>This means SomeService is still required. Use provideMerge instead of provide.
Layer Memoization
Effect memoizes layers by reference identity. The same layer instance used multiple times is constructed only once.
// BAD: calling constructor twice creates two connection pools
const badLayer = Layer.merge(
UserRepo.layer.pipe(
Layer.provide(Postgres.layer({ url: "postgres://...", poolSize: 10 }))
),
OrderRepo.layer.pipe(
Layer.provide(Postgres.layer({ url: "postgres://...", poolSize: 10 })) // different ref!
)
)
// GOOD: store in a constant, same reference shared
const postgresLayer = Postgres.layer({ url: "postgres://...", poolSize: 10 })
const goodLayer = Layer.merge(
UserRepo.layer.pipe(Layer.provide(postgresLayer)),
OrderRepo.layer.pipe(Layer.provide(postgresLayer)) // same ref
)Rule: When using parameterized layer constructors, always store the result in a module-level constant.
Sharing Layers Between Tests
Default: provide a fresh layer per it.effect so state never leaks.
Use it.layer only for expensive shared resources (database connections):
// Preferred: fresh layer per test
it.effect("starts at zero", () =>
Effect.gen(function* () {
const counter = yield* Counter
expect(yield* counter.get()).toBe(0)
}).pipe(Effect.provide(Counter.layer))
)
// Shared: only when you need it
it.layer(Counter.layer)("counter", (it) => {
it.effect("starts at zero", () =>
Effect.gen(function* () {
const counter = yield* Counter
expect(yield* counter.get()).toBe(0)
})
)
})See testing.md for the full worked example.
Project Setup
Table of Contents
- Effect Language Service
- TypeScript Configuration
- Module Settings by Project Type
- Reference Repositories
- Development Workflow
Effect Language Service
The Effect Language Service provides editor diagnostics and compile-time type checking. It catches errors TypeScript alone cannot detect.
Install
bun add -d @effect/language-serviceAdd to tsconfig.json:
{
"$schema": "https://raw.githubusercontent.com/Effect-TS/language-service/refs/heads/main/schema.json",
"compilerOptions": {
"plugins": [{ "name": "@effect/language-service" }]
}
}The $schema field enables autocomplete and validation for plugin options.
Editor Setup
Your editor must use the workspace TypeScript version.
VS Code / Cursor:
// .vscode/settings.json
{
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}Then F1, "TypeScript: Select TypeScript version", "Use workspace version".
JetBrains: Settings, Languages & Frameworks, TypeScript, select workspace version.
Build-Time Diagnostics
Patch TypeScript for CI enforcement:
bunx effect-language-service patchPersist across installs:
{
"scripts": { "prepare": "effect-language-service patch" }
}TypeScript Configuration
Key Settings
{
"compilerOptions": {
// Build performance
"incremental": true,
"composite": true,
// Module system
"target": "ES2022",
"module": "NodeNext",
"moduleDetection": "force",
// Import handling
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true,
// Type safety
"strict": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noImplicitOverride": true,
// Development
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
// Effect
"plugins": [{ "name": "@effect/language-service" }]
}
}Why These Settings
- incremental + composite: Fast rebuilds, monorepo project references
- ES2022 + NodeNext: Modern JS, proper ESM/CJS resolution
- verbatimModuleSyntax: Preserves
import typeexactly - rewriteRelativeImportExtensions: Allows
.tsin imports - strict + exactOptionalPropertyTypes: Maximum type safety
- skipLibCheck: Faster builds (skip node_modules checking)
Module Settings by Project Type
Bundled Apps (Vite, Webpack, esbuild)
{
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"noEmit": true
}
}TypeScript acts as type-checker only. Bundler handles module transformation.
Libraries and Node.js Apps
{
"compilerOptions": {
"module": "NodeNext"
}
}Required for npm packages, Node.js apps, and CLI tools. Enforces Node.js module resolution rules.
Additional library settings:
{
"compilerOptions": {
"declaration": true,
"composite": true,
"declarationMap": true
}
}Rule of thumb: Build tool compiling your code? Use preserve + bundler. TypeScript compiling your code? Use NodeNext.
Reference Repositories
Local clones for searching real implementations and patterns:
- effect-solutions (best practices):
~/Code/kitlangton/effect-solutions/ - effect monorepo (all @effect packages):
~/Code/effect-ts/effect/
Search examples:
# Find ServiceMap usage patterns
grep -r "ServiceMap.Service" ~/Code/kitlangton/effect-solutions/
# Find Schema patterns in effect source
grep -r "Schema.Class" ~/Code/effect-ts/effect/packages/effect/src/
# Find test patterns
grep -r "it.effect" ~/Code/effect-ts/effect/packages/*/test/Development Workflow
From the effect monorepo AGENTS.md:
pnpm install # install
pnpm lint-fix # lint and format
pnpm test run <file> # run tests
pnpm check # type checking (pnpm clean if stuck)
pnpm build # build
pnpm docgen # verify JSDoc examples
pnpm codegen # regenerate barrel files (index.ts)Testing conventions (from Effect source)
- Use
it.effectfor all Effect-based tests, notEffect.runSyncwith regularit - Import
{ assert, describe, it }from@effect/vitest - Use
assertmethods instead ofexpectfrom vitest in Effect tests - Test files live in
packages/*/test/
Testing
Table of Contents
- Setup
- Basic Testing
- Test Function Variants
- Providing Layers
- TestClock
- Test Modifiers
- Logging in Tests
- Worked Example
Setup
Install:
bun add -D vitest @effect/vitest@betaConfig:
// vitest.config.ts
import { defineConfig } from "vitest/config"
export default defineConfig({
test: { include: ["tests/**/*.test.ts"] },
})// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}Basic Testing
Import from @effect/vitest, not vitest:
import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
describe("Calculator", () => {
it("sync test", () => {
expect(1 + 1).toBe(2)
})
it.effect("effect test", () =>
Effect.gen(function* () {
const result = yield* Effect.succeed(1 + 1)
expect(result).toBe(2)
})
)
})Test Function Variants
it.effect
Most common. Provides TestContext (TestClock, TestRandom). Clock starts at 0:
it.effect("processes data", () =>
Effect.gen(function* () {
const result = yield* processData("input")
expect(result).toBe("expected")
})
)it.live
Uses real system clock. Use when you need actual delays or real time:
it.live("real clock", () =>
Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
expect(now).toBeGreaterThan(0) // actual system time
})
)Scoped Resources
Scoping is automatic in v4. The scope closes when the test ends:
it.effect("temp directory cleaned up", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const tempDir = yield* fs.makeTempDirectoryScoped()
yield* fs.writeFileString(`${tempDir}/test.txt`, "hello")
expect(yield* fs.exists(`${tempDir}/test.txt`)).toBe(true)
// scope closes, tempDir is deleted
}).pipe(Effect.provide(NodeFileSystem.layer))
)Providing Layers
Use Effect.provide inline per test:
const testDatabase = Layer.succeed(Database, {
query: (_sql) => Effect.succeed(["mock", "data"]),
})
it.effect("queries database", () =>
Effect.gen(function* () {
const db = yield* Database
const results = yield* db.query("SELECT *")
expect(results.length).toBe(2)
}).pipe(Effect.provide(testDatabase))
)TestClock
it.effect provides TestClock automatically. Use TestClock.adjust to simulate time:
import { TestClock } from "effect/testing"
it.effect("time-based test", () =>
Effect.gen(function* () {
const fiber = yield* Effect.delay(Effect.succeed("done"), "10 seconds").pipe(
Effect.forkChild
)
yield* TestClock.adjust("10 seconds")
const result = yield* Fiber.join(fiber)
expect(result).toBe("done")
})
)Test Modifiers
it.effect.skip("temporarily disabled", () => /* ... */)
it.effect.only("focus on this", () => /* ... */)
it.effect.fails("known bug, expected to fail", () => /* ... */)Logging in Tests
By default, it.effect suppresses log output:
// Option 1: provide a logger
it.effect("with logging", () =>
Effect.gen(function* () {
yield* Effect.log("visible")
}).pipe(Effect.provide(Logger.pretty))
)
// Option 2: it.live enables logging by default
it.live("live with logging", () =>
Effect.gen(function* () {
yield* Effect.log("visible")
})
)Worked Example
Testing the Events service from services-and-layers.md:
Test layers with in-memory state
import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { describe, expect, it } from "@effect/vitest"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type
const EventId = Schema.String.pipe(Schema.brand("EventId"))
type EventId = typeof EventId.Type
const TicketId = Schema.String.pipe(Schema.brand("TicketId"))
type TicketId = typeof TicketId.Type
const RegistrationId = Schema.String.pipe(Schema.brand("RegistrationId"))
type RegistrationId = typeof RegistrationId.Type
class User extends Schema.Class("User")({
id: UserId, name: Schema.String, email: Schema.String,
}) {}
class Registration extends Schema.Class("Registration")({
id: RegistrationId, eventId: EventId, userId: UserId,
ticketId: TicketId, registeredAt: Schema.Date,
}) {}
class Ticket extends Schema.Class("Ticket")({
id: TicketId, eventId: EventId, code: Schema.String,
}) {}
class Email extends Schema.Class("Email")({
to: Schema.String, subject: Schema.String, body: Schema.String,
}) {}
class UserNotFound extends Schema.TaggedErrorClass("UserNotFound")(
"UserNotFound", { id: UserId }
) {}
// Test layers with mutable in-memory state
class Users extends ServiceMap.Service<Users, {
readonly create: (user: User) => Effect.Effect<void>
readonly findById: (id: UserId) => Effect.Effect<User, UserNotFound>
}>()("@app/Users") {
static readonly testLayer = Layer.sync(Users, () => {
const store = new Map<UserId, User>()
const create = (user: User) => Effect.sync(() => void store.set(user.id, user))
const findById = (id: UserId) =>
Option.fromNullishOr(store.get(id)).pipe(
Effect.fromOption,
Effect.catch(() => Effect.fail(new UserNotFound({ id })))
)
return { create, findById }
})
}
class Tickets extends ServiceMap.Service<Tickets, {
readonly issue: (eventId: EventId, userId: UserId) => Effect.Effect<Ticket>
}>()("@app/Tickets") {
static readonly testLayer = Layer.sync(Tickets, () => {
let counter = 0
const issue = (eventId: EventId, _userId: UserId) =>
Effect.sync(() => new Ticket({
id: TicketId.makeUnsafe(`ticket-${counter++}`),
eventId, code: `CODE-${counter}`,
}))
return { issue }
})
}
class Emails extends ServiceMap.Service<Emails, {
readonly send: (email: Email) => Effect.Effect<void>
readonly sent: Effect.Effect<ReadonlyArray<Email>>
}>()("@app/Emails") {
static readonly testLayer = Layer.sync(Emails, () => {
const emails: Array<Email> = []
const send = (email: Email) => Effect.sync(() => void emails.push(email))
const sent = Effect.sync(() => emails)
return { send, sent }
})
}The orchestration service
class Events extends ServiceMap.Service<Events, {
readonly register: (eventId: EventId, userId: UserId) => Effect.Effect<Registration, UserNotFound>
}>()("@app/Events") {
static readonly layer = Layer.effect(Events, Effect.gen(function* () {
const users = yield* Users
const tickets = yield* Tickets
const emails = yield* Emails
const register = Effect.fn("Events.register")(
function* (eventId: EventId, userId: UserId) {
const user = yield* users.findById(userId)
const ticket = yield* tickets.issue(eventId, userId)
const now = yield* Clock.currentTimeMillis
const registration = new Registration({
id: RegistrationId.makeUnsafe(crypto.randomUUID()),
eventId, userId, ticketId: ticket.id,
registeredAt: new Date(now),
})
yield* emails.send(new Email({
to: user.email,
subject: "Event Registration Confirmed",
body: `Your ticket code: ${ticket.code}`,
}))
return registration
}
)
return { register }
}))
}Tests
// provideMerge exposes leaf services for setup/assertions
const testLayer = Events.layer.pipe(
Layer.provideMerge(Users.testLayer),
Layer.provideMerge(Tickets.testLayer),
Layer.provideMerge(Emails.testLayer),
)
describe("Events.register", () => {
it.effect("creates registration with correct data", () =>
Effect.gen(function* () {
const users = yield* Users
const events = yield* Events
const user = new User({
id: UserId.makeUnsafe("user-123"),
name: "Alice", email: "alice@example.com",
})
yield* users.create(user)
const eventId = EventId.makeUnsafe("event-789")
const registration = yield* events.register(eventId, user.id)
expect(registration.eventId).toBe(eventId)
expect(registration.userId).toBe(user.id)
}).pipe(Effect.provide(testLayer))
)
it.effect("sends confirmation email with ticket code", () =>
Effect.gen(function* () {
const users = yield* Users
const events = yield* Events
const emails = yield* Emails
const user = new User({
id: UserId.makeUnsafe("user-456"),
name: "Bob", email: "bob@example.com",
})
yield* users.create(user)
yield* events.register(EventId.makeUnsafe("event-789"), user.id)
const sentEmails = yield* emails.sent
expect(sentEmails).toHaveLength(1)
expect(sentEmails[0].to).toBe("bob@example.com")
expect(sentEmails[0].subject).toBe("Event Registration Confirmed")
expect(sentEmails[0].body).toContain("CODE-")
}).pipe(Effect.provide(testLayer))
)
})Testing Errors with Effect.flip
Swap the success/error channels to assert on errors:
it.effect("rejects invalid input", () =>
Effect.gen(function* () {
const service = yield* MyService
const error = yield* service.process(badInput).pipe(Effect.flip)
expect(error._tag).toBe("ValidationError")
}).pipe(Effect.provide(testLayer))
)Test Isolation with FiberRef
Avoid mutating process.env in parallel tests. Use FiberRef for fiber-local overrides:
import { Effect, FiberRef } from "effect"
// In your module
const ConfigOverride = FiberRef.unsafeMake<string | undefined>(undefined)
const getConfig = Effect.gen(function* () {
const override = yield* FiberRef.get(ConfigOverride)
if (override !== undefined) return override
return process.env.MY_CONFIG ?? "/default/path"
})
// In tests: fiber-local, safe for parallel execution
it.effect("works with custom config", () =>
Effect.gen(function* () {
const result = yield* myEffect
expect(result).toBe(expected)
}).pipe(
Effect.locally(ConfigOverride, "/test/path"), // scoped to this fiber
Effect.provide(TestLayer),
)
)Why FiberRef over process.env mutation:
- Fiber-local (parallel test safe)
- Auto-cleanup (no finally block needed)
- Type-safe
Patterns adapted from artimath/effect-skills (MIT).
Running Tests
bun run test # all tests
bun run test:watch # watch mode
bunx vitest run tests/user.test.ts # specific file
bunx vitest run -t "UserService" # matching pattern