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
| Package | You use it for |
|---|---|
@effx/compiler | Extension, Interpreter, Analysis, Generator, Contribution, error, warning, decodeArgs, GeneratedFile, CompilerFault, Extensions.builtin, and for typed definitions implement, extension, dataOf, laws, LawViolation |
@effx/ir | Node, 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/config | defineConfig (importing it does not start the CLI) |
Anything else exported from @effx/compiler is marked internal (spec 0015). The example's manifest:
{
"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").
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");
| Rule | Source |
|---|---|
The frontend finds Annotate by its @effx/runtime symbol, lowers a literal annotation name and static arguments, and never runs application code | spec 0015, "Proof of extension authoring" |
A name no registered extension owns is EFFX1101 | spec 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 hash | spec 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.
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:
| Part | In the example | Rules |
|---|---|---|
| Interpreter | decodes [{ reason }] with a Schema, builds an Extension node ext:<operation>/deprecated and an ExtensionOf edge to the operation | pure; returns a Contribution; a malformed call is a diagnostic, not a throw (decodeArgs gives EFFX1102 for you) |
| Analysis | one warning per deprecated operation | pure; reads ir and the graph index; an error diagnostic stops generation, a warning does not (packages/compiler/src/pipeline.ts) |
| Generator | deprecated.ts with a JSDoc @deprecated and a constant per operation | returns 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
Extensionnode needs anExtensionOfedge to its owner (EFFX1003otherwise,packages/compiler/src/extensions/core.ts).qualifieris optional; the built-ins set it to the node'stagso their analyses can find their own links. - A node's
dataisSchema.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
EFFX2901andEFFX2902and 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
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).
| Field | Type | Meaning |
|---|---|---|
project | string? | tsconfig to compile; relative to the config file |
outDir | string? | generated-file directory; relative to the config file |
emit | EmitMode? | contract, handlers or all |
target | TargetProfile? | effect-4.0 |
strictAccess | boolean? | promote missing-access warnings to errors |
extensions | Extension[] 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.jsonOutput 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 infoeffx 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
| Claim | How it is tested (extension.test.ts) |
|---|---|
| The CLI reports the warning and writes IR and JSDoc without evaluating the application | runs the real CLI in an isolated copy; the application modules call failOnApplicationImport |
An unregistered custom annotation stays EFFX1101 | compile with Extensions.builtin only |
| Decorator and builder collect the same extension node, edge and generated file | compares 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 JSDoc | calls 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.
/**
* @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 },
});
/**
* @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]);
| Rule | Source |
|---|---|
An applied definition records exactly the { name, args } that @Annotate(name, ...args) records | spec 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 EFFX1101 | packages/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: EFFX1102 | same test |
Only the applied definition records its own export, so only it gets the .annotate(key, ...) that an effect clause writes into the generated endpoint | same test |
A definition module is a leaf (effect and @effx/runtime only), because the config evaluates it: EFFX1306 otherwise | spec 0020 section 9 |
Not available yet
| Item | Status |
|---|---|
| Typed annotation definitions for a class, group or model target | v1 supports target: "operation" only (spec 0020 section 0.4) |
| Running a custom extension without the CLI | not offered; the frontend is private |
| A reserved diagnostic-code range for third parties | none |
| A generated diagnostic catalogue | the diagnostic registry has not landed (Diagnostics) |