| name | writing-effect |
| description | Use when writing or reviewing Effect-TS code (any file importing from "effect" or "@effect/*"). Covers Effect.gen, services via Effect.Service, Schema, error handling with catchTag/catchTags, observability via withSpan/annotateCurrentSpan, and testing with @effect/vitest's it.effect. Targets Effect v4 beta. Defers authoritative answers to the Effect v4 source cloned at `.repos/effect-v4/` by `scripts/setup-repos.sh`. |
Writing Effect
Effect-TS coding guide for this monorepo. Authoritative source: clone Effect v4 source into .repos/effect-v4/ by running pnpm setup:repos (or bash scripts/setup-repos.sh) — matches the convention Effect itself uses (see effect-ts/effect-smol/scripts/worktree-setup.sh and that repo's AGENTS.md, which explicitly points agents at .repos/effect-v4). .repos/ is gitignored, so it stays out of PRs and never appears in pkg.pr.new tarballs.
When you need to verify an API, grep .repos/effect-v4/packages/effect/src/ and .repos/effect-v4/packages/platform-node/src/ directly — docs lag the beta, source doesn't. The cloned repo's own AGENTS.md and LLMS.md are worth a skim. The web docs at https://effect.website/llms-full.txt are useful too but may be v3-flavored in places.
If .repos/effect-v4/ is missing, run pnpm setup:repos and try again. To refresh to upstream main, re-run the same command — it fast-forwards in place. Rules for the cloned repo: read-only, don't import from it in our source (use the effect@beta / @effect/platform-node@beta npm packages instead, both pinned in the workspace catalog).
Version
This monorepo targets Effect v4 beta (pinned in pnpm-workspace.yaml catalog). v4 consolidates many former sub-packages into core effect:
Schema lives in effect (not @effect/schema). Import: import { Schema } from 'effect'.
Stream, Sink, Channel, FileSystem, Path, Terminal, Stdio, Schedule, Pool, Semaphore, Cron, DateTime, Match — all in core effect.
- CLI:
effect/unstable/cli (unstable).
- Child process:
effect/unstable/process + Node binding via @effect/platform-node's NodeChildProcessSpawner (stable).
- HTTP / HttpApi / RPC:
effect/unstable/http, effect/unstable/httpapi, effect/unstable/rpc.
- Vitest:
@effect/vitest. The v4 API differs from v3: use it.effect (it already includes a Scope — no separate it.scoped). The package re-exports all of vitest, so import describe, expect, it from @effect/vitest. No assert export — use expect. Never use Effect.runSync inside it.
When this guide conflicts with what the cloned .repos/effect-v4/ source shows, trust the source and update this skill.
Repo conventions
- Tabs for indentation. Single quotes. Trailing commas. Width 100. Prettier handles all of it.
- Lint via root
pnpm lint (oxlint + prettier). No per-package overrides.
- Prototype repo — break APIs directly, no shims or deprecation cycle. See repo-root
AGENTS.md.
- Capture friction in
packages/<pkg>/notes/friction.md.
Core idioms
Use Effect.gen for async/await-style flow
import { Effect, Random } from 'effect';
Effect.gen(function* () {
yield* Effect.sleep('1 second');
const bool = yield* Random.nextBoolean;
if (bool) {
return yield* Effect.fail('Random boolean was true');
}
return 'Returned value';
}).pipe(Effect.withSpan('tracing span'));
Use Effect.fn for named Effect-returning functions
import { Effect, Random } from 'effect';
const myFn = Effect.fn('myFn')(
function* (x: number, y: number) {
const bool = yield* Random.nextBoolean;
if (bool) return yield* Effect.fail('Random boolean was true');
return x + y;
},
Effect.annotateLogs({ some: 'annotation' }),
(effect, x, y) => Effect.annotateLogs(effect, { x, y }),
);
Avoid try/catch — use Effect.try / Effect.tryPromise
import { Effect, Schema } from 'effect';
class JsonError extends Schema.TaggedError<JsonError>('JsonError')({
cause: Schema.Defect,
}) {}
Effect.gen(function* () {
const parsed = yield* Effect.try({
try: () => JSON.parse('{"invalid": }'),
catch: (cause) => new JsonError({ cause }),
});
const body = yield* Effect.tryPromise({
try: () => fetch('https://example.com').then((r) => r.json()),
catch: (cause) => new JsonError({ cause }),
});
return { parsed, body };
});
Error handling
Effect.catchAll — all errors
Effect.catchAllCause — including defects
Effect.catchTag / Effect.catchTags — specific tagged errors
Effect.catchIf — conditional
someEffect.pipe(
Effect.catchTag('ErrorA', (e) => Effect.log('Caught ErrorA', e)),
Effect.catchTags({
ErrorA: (e) => Effect.log('A', e),
ErrorB: (e) => Effect.log('B', e),
}),
);
Services — the most important pattern
Most Effect code should be written as services. They bundle related Effect functions and let Effect's DI wire them up.
import { Effect, Schema } from 'effect';
export class Database extends Effect.Service<Database>()('Database', {
dependencies: [],
scoped: Effect.gen(function* () {
const query = Effect.fn('Database.query')(function* (sql: string) {
yield* Effect.annotateCurrentSpan({ sql });
return { rows: [] };
});
return { query } as const;
}),
}) {}
export class UserServiceError extends Schema.TaggedError<UserServiceError>('UserServiceError')({
cause: Schema.optional(Schema.Defect),
}) {}
export class UserService extends Effect.Service<UserService>()('UserService', {
dependencies: [Database.Default],
scoped: Effect.gen(function* () {
const database = yield* Database;
const getAll = database.query('SELECT * FROM users').pipe(
Effect.map((r) => r.rows),
Effect.mapError((cause) => new UserServiceError({ cause })),
);
return { getAll } as const;
}),
}) {}
Essential: there should be exactly one Effect.provide in an application, at the top level. Compose Layers via the Layer module instead.
Domain entities via Schema
import { Schema } from 'effect';
export const UserId = Schema.String.pipe(Schema.brand('UserId'));
export type UserId = (typeof UserId).Type;
export class User extends Schema.Class<User>('User')({
id: UserId,
name: Schema.String,
email: Schema.String,
createdAt: Schema.DateTimeUtc,
}) {}
export class UserError extends Schema.TaggedError<UserError>('UserError')({
cause: Schema.optional(Schema.Defect),
message: Schema.String,
}) {}
Observability
Effect.withSpan('name') — attach a span
Effect.fn('name')(fn) — function with span
Effect.annotateCurrentSpan({ k: v }) — add attributes
Effect.log / logInfo / logWarning / logError / logFatal / logDebug / logTrace
Testing with @effect/vitest
In v4 the primary runner is it.effect — it implicitly provides a Scope. it.live runs against the live clock; it.layer(layer)('group', (it) => {...}) shares a Layer across nested tests.
import { Effect } from 'effect';
import { describe, expect, it } from '@effect/vitest';
describe('My Effect tests', () => {
it.effect('runs an Effect', () =>
Effect.gen(function* () {
const result = yield* Effect.succeed('Hello');
expect(result).toBe('Hello');
}),
);
it.effect('handles errors with Effect.flip', () =>
Effect.gen(function* () {
const error = yield* Effect.fail('boom').pipe(Effect.flip);
expect(error).toBe('boom');
}),
);
});
@effect/vitest re-exports all of vitest, so describe, expect, it, etc. come from it. There is no assert export — use expect.
- Never use
Effect.runSync inside it; use it.effect.
When in doubt
Grep .repos/effect-v4/packages/effect/src/ (and .repos/effect-v4/packages/platform-node/src/ for the Node bindings). The cloned source is the source of truth — if missing, run pnpm setup:repos.