Authoring overview
Decorators and builder chains record the same Annotations. Pick the syntax that fits the declaration.
effx has two source syntaxes with one meaning. Decorators and builder chains both record
Annotation values. The compiler reads those annotations, never the syntax that wrote them.
decorator = contribution to IR, never generated behaviour. A decorator returns undefined,
never replaces the method, and generates nothing at runtime. Generated code is ordinary Effect:
AOT analysis is authoritative and generated code is HttpApi, Rpc, Command, with no effx
runtime DI (docs/decisions/0001-aot-compilation-is-authoritative.md,
docs/decisions/0008-generated-code-is-ordinary-effect.md).
decorators ──┐
├─▶ Annotation[] ─▶ collector ─▶ interpreters ─▶ IR ─▶ analyses ─▶ generators ─▶ ordinary Effect
builder ─────┘ (name, args) (frontend) (extensions)Source: packages/runtime/src/Annotation.ts, packages/runtime/src/decorators.ts,
packages/runtime/src/builder.ts, docs/specs/0002-source-frontend.md.
The Annotation record
| Field | Type | Meaning |
|---|---|---|
name | string | Annotation name, for example Query, Http.Get, Http.Contract. |
args | ReadonlyArray<AnnotationValue> | Live argument values. AnnotationValue is string, number, boolean or object. |
Source: packages/runtime/src/Annotation.ts (Annotation, AnnotationValue).
Live values (a real Schema, a real service class) stay live at runtime. The source frontend lowers
them to SchemaRef and SymbolRef references; no schema or function is serialized into the IR
(docs/decisions/0004-schemas-preserved-by-symbol-reference.md, docs/specs/0002-source-frontend.md).
Reading annotations at runtime
Reflect.annotationsOf(target) returns the recorded annotations, or an empty array. It is exported as
Reflect from @effx/runtime.
import { Reflect } from "@effx/runtime";
Reflect.annotationsOf(UserOperations.get); // ReadonlyArray<Annotation>Decorators are applied bottom-up but written top-down. record prepends, so the recorded order equals
source order, which is also the order a builder chain produces. The registry is a module-level
WeakMap keyed by the decorated class or static method function (context.metadata exists under Bun,
Symbol.metadata does not).
Law (spec 0002): for the same operation written both ways,
Reflect.annotationsOf(Class.method) deep-equals builderValue.annotations.
The compiled IR is identical except the handler SymbolRef (UserOperations.get versus getUser.handler).
Source: packages/runtime/src/Annotation.ts (record, annotationsOf),
packages/runtime/src/reflect.ts, docs/specs/0002-source-frontend.md §Registry, §Law.
Decorator to builder map
Every decorator has a builder step that appends exactly the annotation the decorator records.
| Decorator | Builder step | Recorded annotation | Page |
|---|---|---|---|
@Query(options) | Operation.query(options) | Query [options] | Operations |
@Command(options) | Operation.command(options) | Command [options] | Operations |
@Errors(...schemas) | .errors(...schemas) | Errors [...schemas] | Operations |
@Requirements(...services) | .requirements(...services) | Requirements [...services] | Operations |
@Authorize(capability) | .authorize(capability) | Authorize [capability] | Operations |
@Rpc(name) | .rpc(name) | Rpc [name] | Operations |
@Cli(words) | .cli(words) | Cli [words] | Operations |
@Http.Get(path) to @Http.Delete(path) | .http.get(path) to .http.delete(path) | Http.Get [path] to Http.Delete [path] | HTTP |
@Http.Group(options) on a class | Http.group(options) value | Http.Group [options] | HTTP |
| (class-level group, no per-method decorator) | .in(group) | Http.In [group] (builder only; never persisted) | HTTP |
@Http.Contract(options) | .http.contract(options) | Http.Contract [options] | HTTP contract |
@Http.Problems(options) | .http.problems(options) | Http.Problems [options] | Problems and access |
@Http.Access(options) | .http.access(options) | Http.Access [options] | Problems and access |
@Foldkit.Command(options) | .foldkit.command(options) | Foldkit.Command [options] | Foldkit |
@PersistentModel(options) on a class C | Model.persistent(C, options) | PersistentModel [{ ...options, schema: C }] | Persistence and authority |
| the decorated static method itself | .handler(fn) | handler recorded by the collector | this page |
| none | .declare() | operation with binding: "external", no handler | HTTP |
Source: packages/runtime/src/decorators.ts, packages/runtime/src/builder.ts,
docs/specs/0002-source-frontend.md §"Runtime annotation record".
Choosing a syntax
| Situation | Use | Why |
|---|---|---|
The operation has a local Effect handler written as a static method | Decorators | The method is the handler; E and R are inferred from its return type. |
| The operation is value-shaped, or composed in a single exported expression | Builder chain | The chain ends in .handler(fn) and evaluates to { _tag: "Operation", annotations, handler }. |
| The contract lives apart from its implementation (another package binds the handler) | Builder chain with .declare() | Decorators need a real method. A declaration-only operation has no callable handler (spec 0009, spec 0010). |
| Several operations share root, group, middleware, problem registry, access defaults | @Http.Group class, or .in(group) | Group defaults are merged per field into each operation (spec 0013). |
| The model schema class is yours to decorate | @PersistentModel | The decorated class is recorded as schema. |
| The model schema class cannot be decorated | Model.persistent(C, options) | Same annotation, passed as a value. |
Rules the collector enforces for both syntaxes (spec 0002 §Recognition):
- A decorated non-static method, a decorated field, or a builder chain not ending in
.handler(fn)or.declare()isEFFX1104(error); the declaration is skipped. - A handler that does not return
Effect<A, E, R>isEFFX1105(error). - An
Http.*,Errors,Requirements,Rpc,Cli,Authorize,Foldkit.Commandannotation on a declaration with noQueryorCommandhas no operation to attach to:EFFX1103(packages/compiler/src/extensions/core.ts).
One operation, two syntaxes
Decorators, from the users example:
import { Authorize, Cli, Command, Errors, Http, Query, Requirements, Rpc } from "@effx/runtime";
import { Effect } from "effect";
import { ChangeEmail, Read } from "./capabilities.ts";
import { ChangeEmailInput, EmailTaken, GetUserInput, UserNotFound } from "./schemas.ts";
import { Users } from "./services.ts";
import { User } from "./user.ts";
export class UserOperations {
@Query({ name: "User.Get", input: GetUserInput, success: User.Public })
@Http.Get("/users/:id")
@Rpc("User.Get")
@Cli("users get")
@Authorize(Read)
static get(input: typeof GetUserInput.Type) {
return Effect.gen(function* () {
const users = yield* Users;
const user = yield* users.find(input.id);
return { id: user.id, displayName: user.displayName };
});
}
@Command({ name: "User.ChangeEmail", input: ChangeEmailInput, success: User.Self })
@Http.Patch("/users/:id/email")
@Rpc("User.ChangeEmail")
@Cli("users change-email")
@Authorize(ChangeEmail)
@Errors(UserNotFound, EmailTaken)
@Requirements(Users)
static changeEmail(input: typeof ChangeEmailInput.Type) {
return Effect.gen(function* () {
const users = yield* Users;
return yield* users.setEmail(input.id, input.email);
});
}
}
The same declaration as a builder chain (compiler test fixture; it adds an Audit service):
import { Effect } from "effect";
import { Operation } from "@effx/runtime";
import { ChangeEmail, Read } from "./capabilities.ts";
import { EmailTaken, UserNotFound } from "./errors.ts";
import { ChangeEmailInput, GetUserInput } from "./schemas.ts";
import { Audit, Users } from "./services.ts";
import { User } from "./user.ts";
export const getUser = Operation.query({
name: "User.Get",
input: GetUserInput,
success: User.Public,
})
.http.get("/users/:id")
.rpc("User.Get")
.cli("users get")
.authorize(Read)
.handler((input: typeof GetUserInput.Type) =>
Effect.gen(function* () {
const users = yield* Users;
return yield* users.find(input.id);
}),
);
export const changeUserEmail = Operation.command({
name: "User.ChangeEmail",
input: ChangeEmailInput,
success: User.Self,
})
.http.patch("/users/:id/email")
.rpc("User.ChangeEmail")
.cli("users change-email")
.authorize(ChangeEmail)
.errors(UserNotFound, EmailTaken)
.requirements(Users, Audit)
.handler((input: typeof ChangeEmailInput.Type) =>
Effect.gen(function* () {
const users = yield* Users;
const audit = yield* Audit;
yield* audit.log(`change email of ${input.id}`);
return yield* users.setEmail(input.id, input.email);
}),
);