Effect-TS Patterns — Reactive Agents
SkillAI & modelsMandatory Effect-TS coding patterns for the Reactive Agents framework. Use when writing any TypeScript code, creating services, defining types, handling errors, or composing layers in this project.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Effect-TS Patterns — Reactive Agents skill
What this skill tells your AI
The instructions your AI receives, as published by tylerjrbuell/reactive-agents-ts in .agents/skills/effect-ts-patterns/SKILL.md and read by ahel’s review.
Every file in this project MUST follow these patterns. Violations will break the build and type system.
Types: Schema.Struct (NEVER plain interfaces)
import { Schema } from "effect";
// Branded IDs
export const AgentId = Schema.String.pipe(Schema.brand("AgentId"));
export type AgentId = typeof AgentId.Type;
// Struct definitions
export const AgentSchema = Schema.Struct({
id: AgentId,
name: Schema.String,
status: Schema.Literal("idle", "running", "completed", "failed"),
description: Schema.optional(Schema.String),
tags: Schema.Array(Schema.String),
metadata: Schema.optional(
Schema.Record({ key: Schema.String, value: Schema.Unknown }),
),
});
export type Agent = typeof AgentSchema.Type;
Rules:
- ALWAYS use
Schema.Structfor data shapes — neverinterfaceortype { ... } - ALWAYS use
Schema.brand()for ID types - ALWAYS use
Schema.Literal()for union/enum values - ALWAYS use
Schema.optional()for optional fields — never?: - ALWAYS derive the TypeScript type with
typeof XxxSchema.Type
Errors: Data.TaggedError (NEVER throw)
import { Data } from "effect";
export class AgentError extends Data.TaggedError("AgentError")<{
readonly message: string;
}> {}
export class TaskError extends Data.TaggedError("TaskError")<{
readonly message: string;
readonly taskId: string;
}> {}
// Union type for service signatures
export type CoreErrors = AgentError | TaskError;
Rules:
- NEVER use
throw new Error() - ALWAYS use
Data.TaggedError("UniqueTag")<{ ... }> - The tag string MUST match the class name exactly
- All fields MUST be
readonly - ALWAYS create a union type for each package's errors
Services: Context.Tag + Layer.effect (NEVER OOP classes)
import { Effect, Context, Layer, Ref } from "effect";
// Step 1: Define the service tag with its interface
export class MyService extends Context.Tag("MyService")<
MyService,
{
readonly doWork: (input: string) => Effect.Effect<string, MyError>;
readonly getState: () => Effect.Effect<ReadonlyMap<string, string>, never>;
}
>() {}
// Step 2: Create the Live layer
export const MyServiceLive = Layer.effect(
MyService,
Effect.gen(function* () {
// Resolve dependencies
const dep = yield* OtherService;
// Create mutable state via Ref
const state = yield* Ref.make(new Map<string, string>());
// Return the service implementation
return {
doWork: (input) =>
Effect.gen(function* () {
yield* Ref.update(state, (m) => new Map(m).set(input, input));
return yield* dep.process(input);
}),
getState: () => Ref.get(state),
};
}),
);
Rules:
- Services ALWAYS extend
Context.Tag("ServiceName")<ServiceTag, Interface>() - The tag string MUST match the class name
- Implementations ALWAYS use
Layer.effect(Tag, Effect.gen(...)) - State ALWAYS uses
Ref— never mutable variables - Dependencies are resolved with
yield* OtherServiceinsideEffect.gen - All methods return
Effect.Effect<Success, Error>
Services: Effect.Service (app-level shortcut, since Effect 3.9)
Effect.Service fuses the tag + layer into one class. Use it for app-level services with exactly one concrete implementation and no swap-out need (e.g. a single package's internal orchestrator). Keep the two-step Context.Tag + Layer.effect pattern above for library-exported services where multiple implementations (live/test/mock) are expected — most of this framework's cross-package services fall in that bucket, so Effect.Service should stay the exception, not the default.
import { Effect } from "effect";
export class Greeter extends Effect.Service<Greeter>()("Greeter", {
effect: Effect.gen(function* () {
const dep = yield* OtherService;
return {
greet: (name: string) => Effect.succeed(`Hello ${name}`),
};
}),
}) {}
// Usage: Greeter.Default is the auto-generated Layer
Effect.gen(function* () {
const greeter = yield* Greeter;
return yield* greeter.greet("world");
}).pipe(Effect.provide(Greeter.Default));
Rules:
- Only for services with one canonical implementation, internal to a package
- Do NOT use for any service that ships a
Live/Testlayer pair — useContext.Tag+Layer.effectinstead - Still returns
Effect.Effect<Success, Error>methods; still nothrow, no raw mutable state
Scoped Resources: Layer.scoped for cleanup
export const MemoryDatabaseLive = Layer.scoped(
MemoryDatabase,
Effect.acquireRelease(
Effect.sync(() => {
const db = new Database(dbPath, { create: true });
db.exec("PRAGMA journal_mode=WAL");
return db;
}),
(db) => Effect.sync(() => db.close()),
).pipe(
Effect.map((db) => ({
query: db.query.bind(db),
exec: db.exec.bind(db),
close: () => Effect.sync(() => db.close()),
})),
),
);
Rules:
- Use
Layer.scoped+Effect.acquireReleasefor resources needing cleanup (DB, files, connections) - Acquire returns the resource; release cleans it up
- Both acquire and release must be wrapped in
Effect.syncorEffect.tryPromise
Synchronous Operations: Effect.sync
// bun:sqlite is synchronous — use Effect.sync
const rows = yield * Effect.sync(() => db.query("SELECT * FROM t").all());
// Pure computations
const value = yield * Effect.sync(() => computeHash(input));
Async Operations: Effect.tryPromise
// HTTP calls, file I/O, external APIs
const data =
yield *
Effect.tryPromise({
try: () => fetch(url).then((r) => r.json()),
catch: (e) => new FetchError({ message: String(e) }),
});
Rules:
- ALWAYS provide a
catchfunction that returns one of yourData.TaggedErrortypes - NEVER use raw
await
Layer Composition: Layer.mergeAll + Layer.provide
// Merge independent layers
export const createMyLayer = () => Layer.mergeAll(ServiceALive, ServiceBLive);
// Provide dependencies from one layer to another
export const createMyLayer = () =>
Layer.mergeAll(ServiceALive, ServiceBLive.pipe(Layer.provide(ServiceALive)));
// Full package layer factory
export const createCoreLayer = () =>
Layer.mergeAll(
EventBusLive,
AgentServiceLive.pipe(Layer.provide(EventBusLive)),
TaskServiceLive.pipe(Layer.provide(EventBusLive)),
ContextWindowManagerLive,
);
Rules:
- Every package exports a
createXxxLayer()factory function - Use
Layer.provide()to wire dependencies between services - Use
Layer.mergeAll()to combine independent services - The factory function takes configuration params if needed
Optional Dependencies: Effect.serviceOption
import { Context } from "effect";
// When a dependency may not be present in the runtime
const maybeReasoning =
yield *
Effect.serviceOption(
Context.GenericTag<ReasoningServiceInterface>("ReasoningService"),
);
const result = Option.match(maybeReasoning, {
onNone: () => executeDefaultPath(),
onSome: (svc) => svc.selectStrategy(context),
});
Pattern Checklist
Before committing any file, verify:
- All types use
Schema.Struct, not interfaces (exception: strategy input interfaces with Effect types) - All IDs use
Schema.brand() - All errors use
Data.TaggedError - All library-exported services use
Context.Tag+Layer.effect; single-impl internal services may useEffect.Service - All state uses
Ref - No
throw, no rawawait, no mutable variables - No
@ts-ignoreor@ts-expect-error— ever -
as anyonly in test mocks or untyped meta bags (comment why) - New behavior emits EventBus events
- Computable fields are deterministic (not LLM-generated)
- Package exports a
createXxxLayer()factory -
index.tsre-exports all public types, errors, services, and the layer factory - File names kebab-case, types PascalCase, functions camelCase
Version Note (checked 2026-09-03)
Repo pins effect@^3.10.0; installed 3.19.18; npm latest 3.22.1. No breaking changes found in that range — safe to bump lockfile, but run full build+test after (bun run build && bun test), don't assume. Data.TaggedError remains the default error pattern; Schema.TaggedError is only worth reaching for when the error itself needs schema decode/encode across a wire boundary — no changelog evidence it's now "preferred" generally.
See CODING_STANDARDS.md for the full authoritative reference.
Signals
- GitHub stars
- 27
- Forks
- 4
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
effect-ts-patterns- Source
- github.com/tylerjrbuell/reactive-agents-ts