effx
Extensions

Writing an extension

Add a custom annotation to effx. Register an Extension in effx.config.ts, interpret the annotation into IR, check it, and generate files from it.

An extension gives a custom annotation meaning. It adds IR and generated files; it never adds behaviour (The invariant). This guide follows the tested reference example examples/extension-openapi-tags (spec 0015), end to end:

 @Annotate("example.deprecated", {...})  ──┐
 .annotate("example.deprecated", {...})  ──┼─► frontend ─► Annotation ─► interpreter ─► IR node + ExtensionOf edge
                                           │                                              │
 effx.config.ts ── extensions: [ext] ──────┘                       analysis ◄─────────────┤
                                                                   generator ◄────────────┘

Sources: docs/specs/0015-config-and-extensions.md, packages/cli/src/config.ts, packages/runtime/src/{decorators,builder}.ts, packages/compiler/src/Extension.ts, and the example files included below. For the contract's fields, read Extensions.

Third-party extensions run through the CLI with a config. There is no public way to run the pipeline over source from your own program: the TypeScript frontend (@effx/frontend-ts) is private and bundled into the CLI (spec 0015).

What you depend on

PackageYou use it for
@effx/compilerExtension, Interpreter, Analysis, Generator, Contribution, error, warning, decodeArgs, GeneratedFile, CompilerFault, Extensions.builtin, and for typed definitions implement, extension, dataOf, laws, LawViolation
@effx/irNode, StableId, IRGraph to build and read IR
@effx/runtime@Annotate and .annotate in application source; Annotation.define and A to declare a typed annotation
@effx/cli/configdefineConfig (importing it does not start the CLI)

Anything else exported from @effx/compiler is marked internal (spec 0015). The example's manifest:

examples/extension-openapi-tags/package.json
{
  "name": "@effx-examples/extension-openapi-tags",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "effx:check": "bun ../../scripts/effx.ts check",
    "effx:build": "bun ../../scripts/effx.ts build"
  },
  "dependencies": {
    "@effx/cli": "workspace:*",
    "@effx/compiler": "workspace:*",
    "@effx/ir": "workspace:*",
    "@effx/runtime": "workspace:*",
    "effect": "4.0.0"
  },
  "devDependencies": {
    "@effect/platform-bun": "4.0.0",
    "@effect/vitest": "4.0.0",
    "@effx/frontend-ts": "workspace:*",
    "vitest": "5.0.3"
  }
}

1. Annotate in source

@Annotate(name, ...args) and .annotate(name, ...args) are the one generic way to attach a custom annotation. The runtime only records the call (packages/runtime/src/decorators.ts: "an extension, not the runtime, gives it meaning").

examples/extension-openapi-tags/src/operations.ts
import { Annotate, Query } from "@effx/runtime";
import { Effect } from "effect";
import { failOnApplicationImport } from "../test/import-trap.ts";
import { Found, Lookup } from "./schemas.ts";

export class DeprecatedOperations {
  @Query({ name: "Example.Lookup", input: Lookup, success: Found })
  @Annotate("example.deprecated", { reason: "Use Example.Find instead" })
  static lookup(input: typeof Lookup.Type) {
    return Effect.succeed({ id: input.id });
  }
}

// Executing this application module is always an error. The AOT frontend reads source only.
failOnApplicationImport("operations");
RuleSource
The frontend finds Annotate by its @effx/runtime symbol, lowers a literal annotation name and static arguments, and never runs application codespec 0015, "Proof of extension authoring"
A name no registered extension owns is EFFX1101spec 0015; packages/compiler/src/pipeline.ts
Decorator and builder give the same extension node and owner edge. The full IR hashes can differ in the authored handler's SymbolRef, so compare a handler-neutral projection, not the hashspec 0015

(The example's failOnApplicationImport calls exist only to prove the compiler never evaluates these modules. Do not copy them.)

2. Write the extension

An Extension is a name, an interpreter per annotation name, a list of analyses and a list of generators.

examples/extension-openapi-tags/deprecated-extension.ts
import { Effect, Option, Schema } from "effect";
import {
  Contribution,
  CompilerFault,
  CoreDiagnostics,
  type Extension,
  type GeneratedFile,
} from "@effx/compiler";
import { IRGraph, Node, StableId } from "@effx/ir";

const name = "example.deprecated";

const dataSchema = Schema.Struct({ reason: Schema.String });

const argsSchema = Schema.Tuple([dataSchema]);

const isData = Schema.is(dataSchema);

const stringJson = Schema.fromJsonString(Schema.String);

/** Static annotation arguments are decoded before contributing semantic IR. */
const interpretDeprecated: Extension["interpreters"][string] = (annotation, declaration, ctx) => {
  const parsed = Schema.decodeUnknownOption(argsSchema)(annotation.args);

  if (Option.isNone(parsed))
    return Contribution.diagnostics(
      CoreDiagnostics["EFFX2902"].emit({ _tag: "Arguments", subject: declaration.id }),
    );

  if (Option.isNone(ctx.operationId))
    return Contribution.diagnostics(
      CoreDiagnostics["EFFX2902"].emit({ _tag: "Operation", subject: declaration.id }),
    );

  const operationId = ctx.operationId.value;
  const id = StableId.make("ext", `${StableId.nameOf(operationId)}/deprecated`);

  const node = Node.make({
    _tag: "Extension",
    id,
    extension: name,
    tag: "Deprecated",
    data: { reason: parsed.value[0].reason },
  });

  return Contribution.make([node], [{ kind: "ExtensionOf", from: id, to: operationId }]);
};

/** Warning only: compilation may still generate the IR-derived declaration. */
const warnDeprecated: Extension["analyses"][number] = (ir, index) =>
  ir.nodes.flatMap((node) => {
    if (node._tag !== "Extension" || node.extension !== name || !isData(node.data)) return [];
    const reason = node.data.reason;

    return IRGraph.outgoing(index, node.id, "ExtensionOf").map((edge) =>
      CoreDiagnostics["EFFX2901"].emit({ operation: StableId.nameOf(edge.to), reason }),
    );
  });

/** Keep authored text inside a single JSDoc line even if the IR contains a newline or closing comment. */
const jsdocText = (reason: string): string => reason.replace(/\s+/gu, " ").replace(/\*\//gu, "* /");

/** Generated bytes are a pure projection of IR and its index, never of the application module. */
const generateDeprecated: Extension["generators"][number] = (ir, index) =>
  Effect.gen(function* () {
    const lines: Array<string> = [];
    let ordinal = 0;

    for (const node of ir.nodes) {
      if (node._tag !== "Extension" || node.extension !== name || !isData(node.data)) continue;

      for (const edge of IRGraph.outgoing(index, node.id, "ExtensionOf")) {
        const owner = IRGraph.nodeOf(index, edge.to);

        if (Option.isNone(owner) || owner.value._tag !== "Operation") continue;

        const encoded = yield* Schema.encodeEffect(stringJson)(owner.value.name).pipe(
          Effect.mapError(
            (cause) =>
              new CompilerFault({
                stage: "generate",
                message: "cannot encode deprecated operation name",
                cause,
              }),
          ),
        );

        lines.push(
          `/** @deprecated ${jsdocText(node.data.reason)} */`,
          `export const deprecated_${ordinal++} = ${encoded} as const;`,
        );
      }
    }

    const files: ReadonlyArray<GeneratedFile> =
      lines.length === 0 ? [] : [{ path: "deprecated.ts", contents: lines.join("\n") + "\n" }];

    return files;
  });

/** No application import: this module is safe to evaluate as a selected config dependency. */
export const deprecatedExtension: Extension = {
  name,
  interpreters: { [name]: interpretDeprecated },
  analyses: [warnDeprecated],
  generators: [generateDeprecated],
};

What each part does, in this file:

PartIn the exampleRules
Interpreterdecodes [{ reason }] with a Schema, builds an Extension node ext:<operation>/deprecated and an ExtensionOf edge to the operationpure; returns a Contribution; a malformed call is a diagnostic, not a throw (decodeArgs gives EFFX1102 for you)
Analysisone warning per deprecated operationpure; reads ir and the graph index; an error diagnostic stops generation, a warning does not (packages/compiler/src/pipeline.ts)
Generatordeprecated.ts with a JSDoc @deprecated and a constant per operationreturns Effect<ReadonlyArray<GeneratedFile>, CompilerFault>; a pure projection of IR and index, deterministic, never reads application modules; output is sorted by path

Rules that are easy to miss:

  • Every Extension node needs an ExtensionOf edge to its owner (EFFX1003 otherwise, packages/compiler/src/extensions/core.ts). qualifier is optional; the built-ins set it to the node's tag so their analyses can find their own links.
  • A node's data is Schema.Json. Decode it with a Schema in analyses and generators instead of casting.
  • Return no file for no data. An empty placeholder file is a bug.
  • Diagnostic codes: no range is reserved for third parties. The example picks EFFX2901 and EFFX2902 and says external extensions must choose their own namespace.
  • No ts.* object in the IR; locations live in the manifest (spec 0001).

3. Register it in effx.config.ts

examples/extension-openapi-tags/effx.config.ts
import { defineConfig } from "@effx/cli/config";
import { deprecatedExtension } from "./deprecated-extension.ts";

// Only the selected config and its extension are evaluated by the CLI.
// Neither imports src/operations.ts or its handler.
export default defineConfig({
  project: "tsconfig.json",
  extensions: [deprecatedExtension],
  generators: { http: false, rpc: false, cli: false, client: false, foldkit: false },
});

The CLI discovers effx.config.ts beside the selected tsconfig for check, build, inspect and graph, or loads the file named by --config. It evaluates that one module and nothing from your application. An explicit file that is missing or invalid fails before compilation (spec 0015).

FieldTypeMeaning
projectstring?tsconfig to compile; relative to the config file
outDirstring?generated-file directory; relative to the config file
emitEmitMode?contract, handlers or all
targetTargetProfile?effect-4.0
strictAccessboolean?promote missing-access warnings to errors
extensionsExtension[] or (builtin) => Extension[]an array appends to the built-ins; the callback receives them and returns the complete ordered list
generators{ http?, rpc?, cli?, client?, foldkit? }false turns off one built-in file generator. Omitted means on

Source: packages/cli/src/config.ts. Config values rank below CLI flags and above the tsconfig effx block (Project config).

A generator toggle only stops files. It never changes interpretation, analyses, the IR or the semantic hash, and it never turns off a custom extension's generator. http: false turns off both the HTTP and the guard file generators, not HTTP validation (spec 0015). The example turns all five built-in generators off so only deprecated.ts is written.

The config module and everything it imports must stay free of application code that declares operations. The example's extension imports only @effx/compiler, @effx/ir and effect.

4. Run it

cd examples/extension-openapi-tags
bun ../../scripts/effx.ts check --project tsconfig.json

Output of that command when this page was written (EFFX0001 and EFFX2204 are built-in warnings; the TypeScript versions depend on your install):

  EFFX0001 warning  effx analyses with TypeScript 6.0.3 but the project pins typescript 7.0.2; tsc/tsgo remains the authoritative type gate
  EFFX2204 warning  Example.Lookup: no schema-addressable errors inferred; boundary will expose none
  EFFX2901 warning  Example.Lookup is deprecated: Use Example.Find instead
0 error(s), 3 warning(s), 0 info

effx build then writes the IR with the extension node and its ExtensionOf edge, and .effx/generated/deprecated.ts. The example's test asserts the exact generated text:

/** @deprecated Use Example.Find instead */
export const deprecated_0 = "Example.Lookup" as const;

Without the config the same project fails with EFFX1101: "no extension interprets this annotation" on the custom annotation. The example's test asserts that.

Source: examples/extension-openapi-tags/test/extension.test.ts.

What the example proves

ClaimHow it is tested (extension.test.ts)
The CLI reports the warning and writes IR and JSDoc without evaluating the applicationruns the real CLI in an isolated copy; the application modules call failOnApplicationImport
An unregistered custom annotation stays EFFX1101compile with Extensions.builtin only
Decorator and builder collect the same extension node, edge and generated filecompares node, edges, file, and a handler-neutral canonical form and hash
The generator is lazy and a change to the IR alone changes the emitted JSDoccalls the generator on a modified IR

Typed definitions

@Annotate is the untyped floor: tsc cannot check its arguments and you write the interpreter by hand. Annotation.define (@effx/runtime) is the typed layer over it (spec 0020). You declare the name, the target and the argument shape once; the decorator, the .with(...) builder argument, the frontend lowering and the compiler decode all derive from that one description. implement and extension (@effx/compiler) are the compiler half. The result is an ordinary Extension that you list in effx.config.ts next to a hand-written one.

ai-docs/src/06_custom-extensions/03_define-annotation.ts
/**
 * @title Declaring an annotation
 *
 * `Annotation.define` declares a user annotation once: its name, where it may attach and the
 * argument shape. The derived decorator and builder argument are typed from `args`, and the
 * compiler lowers and decodes the same description. Keep this file a leaf module: it may
 * import only `effect` and `@effx/runtime` (spec 0020, EFFX1306), so loading it never loads
 * application code.
 */
import { A, Annotation } from "@effx/runtime";
import { Context } from "effect";

// An Effect annotation key: the generated HttpApi endpoint carries the value, typed by `tsc`, and a
// hand-written middleware reads it back with `Context.getOption(endpoint.annotations, RateLimitPolicy)`.
// The literal id is what a static lift matches (spec 0020 section 4).
export class RateLimitPolicy extends Context.Service<
  RateLimitPolicy,
  { readonly perMinute: number; readonly burst?: number }
>()("app/RateLimit") {}

export const RateLimit = Annotation.define({
  // Namespaced and unique across all extensions (EFFX1302 on a clash).
  name: "app.RateLimit",
  // v1 user annotations attach to operations only (spec 0020 section 0.4).
  target: "operation",
  // `A.*` is the closed set of argument shapes the TypeScript frontend can read from source
  // without running your code. `tsc` checks the shape at the use site; value constraints such as a
  // minimum are decoded by the compiler (EFFX1102).
  args: { perMinute: A.int, burst: A.optional(A.int) },
  // The compiler rejects a second `@RateLimit` on one operation.
  cardinality: "one",
  // Optional default writer: generated endpoints get `.annotate(RateLimit.effect.key, { ... })`.
  // `tsc` checks the written value against the key's shape in the generated file.
  effect: { target: "endpoint", key: RateLimitPolicy },
});
ai-docs/src/06_custom-extensions/05_implement-annotation.ts
/**
 * @title Giving a declared annotation meaning
 *
 * `implement` is the compiler half. With no `read` it records the arguments as a declarative
 * `Extension` node `ext:<name>/<operation>` linked by `ExtensionOf`; analyses and generators add
 * checks and files. `extension` bundles implementations into an ordinary `Extension`, the value
 * you list in `effx.config.ts`.
 *
 * An optional `lift.recognize` is a pure, synchronous inverse of a source `.annotate` site. It reads
 * the frontend's neutral Term, returns typed `ReadArgs<D>`, and reports a non-match with a closed error;
 * the core validates the result with the definition's own cached plan codec. Pass the selected
 * extension registry explicitly to `lift(model, input, registry)`.
 */
import {
  CoreDiagnostics,
  type DefinitionLift,
  type Diagnostic,
  dataOf,
  extension,
  implement,
} from "@effx/compiler";
import { IRGraph } from "@effx/ir";
import { Option, Predicate, Result } from "effect";
import { RateLimit } from "./03_define-annotation.ts";

const recognizeRateLimit: DefinitionLift<typeof RateLimit>["recognize"] = (site) => {
  if (site.value._tag !== "Obj")
    return Result.fail({ _tag: "Unsupported", construct: "a literal RateLimit object" });

  const entries = site.value.entries;
  const count = (name: string) => entries.filter((entry) => entry.key === name).length;

  const integer = (name: string) => {
    const value = entries.find((entry) => entry.key === name)?.value;

    return value?._tag === "Lit" && Predicate.isNumber(value.json) && Number.isInteger(value.json)
      ? value.json
      : undefined;
  };

  const perMinute = integer("perMinute");
  const burst = integer("burst");
  const burstCount = count("burst");

  if (
    perMinute === undefined ||
    count("perMinute") !== 1 ||
    burstCount > 1 ||
    (burstCount === 1 && burst === undefined) ||
    entries.some((entry) => entry.key !== "perMinute" && entry.key !== "burst")
  )
    return Result.fail({ _tag: "Unsupported", construct: "a literal RateLimit object" });

  const args = burst === undefined ? { perMinute } : { perMinute, burst };

  return Result.succeed([args]);
};

const rateLimit = implement(RateLimit, {
  lift: { recognize: recognizeRateLimit },
  // Analyses read the normalized IR and return diagnostics (data, not failures).
  analyze: (ir, index) =>
    ir.nodes.flatMap((node): ReadonlyArray<Diagnostic> => {
      if (node._tag !== "Operation") return [];

      // `dataOf` decodes the declarative node with the same schema the compiler derived from `args`.
      const limit = dataOf(RateLimit, ir, node.id);

      if (Option.isNone(limit)) return [];

      const [options] = limit.value;

      const exposed = IRGraph.outgoing(index, node.id, "ExposedAs").some(
        (edge) => edge.qualifier === "http",
      );

      if (!exposed) {
        return [CoreDiagnostics["EFFX9101"].emit({ subject: node.name })];
      }

      return options.perMinute > 10_000
        ? [CoreDiagnostics["EFFX9102"].emit({ subject: node.name, perMinute: options.perMinute })]
        : [];
    }),
});

// An ordinary `Extension`. List it in `effx.config.ts`: the CLI compiles with the built-ins
// followed by the config's extensions (spec 0015), and lowers `@RateLimit(...)` by the plan of
// the definition it carries (spec 0020).
export const appExtension = extension("app", [rateLimit]);
RuleSource
An applied definition records exactly the { name, args } that @Annotate(name, ...args) recordsspec 0020 section 2.1
A name with a registered definition lowers by the definition's plan in either spelling. A name without one lowers generically. An unregistered name stays EFFX1101packages/frontend-ts/src/collect.ts; packages/cli/test/config-annotation-definitions.test.ts
A wrong value reaches the compiler through @Annotate (tsc cannot check it) and is rejected by the definition's own decode: EFFX1102same test
Only the applied definition records its own export, so only it gets the .annotate(key, ...) that an effect clause writes into the generated endpointsame test
A definition module is a leaf (effect and @effx/runtime only), because the config evaluates it: EFFX1306 otherwisespec 0020 section 9

Not available yet

ItemStatus
Typed annotation definitions for a class, group or model targetv1 supports target: "operation" only (spec 0020 section 0.4)
Running a custom extension without the CLInot offered; the frontend is private
A reserved diagnostic-code range for third partiesnone
A generated diagnostic cataloguethe diagnostic registry has not landed (Diagnostics)

On this page