Effect-TS Patterns — Reactive Agents

SkillAI & models

Mandatory 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.

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.Struct for data shapes — never interface or type { ... }
  • 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* OtherService inside Effect.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/Test layer pair — use Context.Tag + Layer.effect instead
  • Still returns Effect.Effect<Success, Error> methods; still no throw, 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.acquireRelease for resources needing cleanup (DB, files, connections)
  • Acquire returns the resource; release cleans it up
  • Both acquire and release must be wrapped in Effect.sync or Effect.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 catch function that returns one of your Data.TaggedError types
  • 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 use Effect.Service
  • All state uses Ref
  • No throw, no raw await, no mutable variables
  • No @ts-ignore or @ts-expect-error — ever
  • as any only 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.ts re-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