effx
Authoring

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

FieldTypeMeaning
namestringAnnotation name, for example Query, Http.Get, Http.Contract.
argsReadonlyArray<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.

DecoratorBuilder stepRecorded annotationPage
@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 classHttp.group(options) valueHttp.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 CModel.persistent(C, options)PersistentModel [{ ...options, schema: C }]Persistence and authority
the decorated static method itself.handler(fn)handler recorded by the collectorthis page
none.declare()operation with binding: "external", no handlerHTTP

Source: packages/runtime/src/decorators.ts, packages/runtime/src/builder.ts, docs/specs/0002-source-frontend.md §"Runtime annotation record".

Choosing a syntax

SituationUseWhy
The operation has a local Effect handler written as a static methodDecoratorsThe method is the handler; E and R are inferred from its return type.
The operation is value-shaped, or composed in a single exported expressionBuilder chainThe 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@PersistentModelThe decorated class is recorded as schema.
The model schema class cannot be decoratedModel.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() is EFFX1104 (error); the declaration is skipped.
  • A handler that does not return Effect<A, E, R> is EFFX1105 (error).
  • An Http.*, Errors, Requirements, Rpc, Cli, Authorize, Foldkit.Command annotation on a declaration with no Query or Command has no operation to attach to: EFFX1103 (packages/compiler/src/extensions/core.ts).

One operation, two syntaxes

Decorators, from the users example:

examples/users/src/operations.ts
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):

packages/frontend-ts/test/fixtures/users/src/operations.builder.ts
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);
    }),
  );

In this section

On this page