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:
Schemalives ineffect(not@effect/schema). Import:import { Schema } from 'effect'.Stream,Sink,Channel,FileSystem,Path,Terminal,Stdio,Schedule,Pool,Semaphore,Cron,DateTime,Match— all in coreeffect.- CLI:
effect/unstable/cli(unstable). - Child process:
effect/unstable/process+ Node binding via@effect/platform-node'sNodeChildProcessSpawner(stable). - HTTP / HttpApi / RPC:
effect/unstable/http,effect/unstable/httpapi,effect/unstable/rpc. - Vitest:
@effect/vitest. The v4 API differs from v3: useit.effect(it already includes aScope— no separateit.scoped). The package re-exports all of vitest, so importdescribe,expect,itfrom@effect/vitest. Noassertexport — useexpect. Never useEffect.runSyncinsideit.
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) {
// Always `return yield*` with Effect.fail so TS narrows conditional types
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 errorsEffect.catchAllCause— including defectsEffect.catchTag/Effect.catchTags— specific tagged errorsEffect.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: [],
// ESSENTIAL: always use `scoped:` — gives the service a Scope for finalizers
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], // Database.Default is the auto-generated Layer
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, // prefer DateTimeUtc
}) {}
export class UserError extends Schema.TaggedError<UserError>('UserError')({
cause: Schema.optional(Schema.Defect),
message: Schema.String,
}) {}
Observability
Effect.withSpan('name')— attach a spanEffect.fn('name')(fn)— function with spanEffect.annotateCurrentSpan({ k: v })— add attributesEffect.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/vitestre-exports all of vitest, sodescribe,expect,it, etc. come from it. There is noassertexport — useexpect.- Never use
Effect.runSyncinsideit; useit.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.