Extensions
The Extension contract in @effx/compiler, how the pipeline runs interpreters, analyses and generators, and the built-in extensions.
To add your own extension, read Writing an extension. This page is the contract and the built-in list.
An extension is a plain value with a name, a map of interpreters, a list of analyses and a list of generators. It adds meaning to the IR and output to the build without touching the core.
Source: packages/compiler/src/Extension.ts, packages/compiler/src/pipeline.ts, packages/compiler/src/extensions/index.ts.
The contract
This is the real Extension.ts, included from the repository:
import type { Effect, Option } from "effect";
import type { LiftImplementation } from "./annotation.ts";
import type { DefinitionData } from "@effx/runtime";
import type { ApplicationIR, Edge, GraphIndex, Node, OperationNode, StableId } from "@effx/ir";
import type { Annotation, Collected, Declaration, HttpApiGroupInventory } from "./Collected.ts";
import type { HttpGroupBinding } from "./bindings.ts";
import type { EmitMode, TargetProfile } from "./Collected.ts";
import type { CompilerFault } from "./CompilerFault.ts";
import type { Diagnostic } from "./Diagnostic.ts";
import type { DiagnosticEntry } from "@effx/diagnostics";
import type { MethodCall } from "./generate/term.ts";
/** What one annotation on one declaration contributes to the IR. Never behaviour. */
export interface Contribution {
readonly nodes: ReadonlyArray<Node>;
readonly edges: ReadonlyArray<Edge>;
readonly diagnostics: ReadonlyArray<Diagnostic>;
}
export const Contribution = {
empty: { nodes: [], edges: [], diagnostics: [] } satisfies Contribution,
make: (
nodes: ReadonlyArray<Node> = [],
edges: ReadonlyArray<Edge> = [],
diagnostics: ReadonlyArray<Diagnostic> = [],
): Contribution => ({ nodes, edges, diagnostics }),
diagnostics: (...diagnostics: ReadonlyArray<Diagnostic>): Contribution => ({
nodes: [],
edges: [],
diagnostics,
}),
concat: (contributions: Iterable<Contribution>): Contribution => {
const nodes: Array<Node> = [];
const edges: Array<Edge> = [];
const diagnostics: Array<Diagnostic> = [];
for (const c of contributions) {
nodes.push(...c.nodes);
edges.push(...c.edges);
diagnostics.push(...c.diagnostics);
}
return { nodes, edges, diagnostics };
},
};
/** @internal */
export interface InterpretContext {
/** The operation this declaration defines (from its `Query`/`Command` annotation), if any. */
readonly operationId: Option.Option<StableId.StableId>;
}
export type Interpreter = (
annotation: Annotation,
declaration: Declaration,
ctx: InterpretContext,
) => Contribution;
/** @internal */
export interface AnalysisContext {
readonly strictAccess: boolean;
}
export type Analysis = (
ir: ApplicationIR,
index: GraphIndex,
context: AnalysisContext,
) => ReadonlyArray<Diagnostic>;
export interface GeneratedFile {
readonly path: string;
readonly contents: string;
}
/** One method-call suffix of a generated endpoint (it starts with `.`, e.g. `.annotate(K, v)`). */
export interface EndpointFragmentPart {
readonly call: MethodCall;
}
/**
* What an extension appends to the generated HTTP endpoint of an operation (spec 0020 §6): zero or more
* method-call suffixes, appended after the generator's own annotations. Pure and total: a part carries the
* neutral term of its suffix, so the generator registers the imports it needs when it prints the file.
*/
export type EndpointFragment = (
operation: OperationNode,
ctx: { readonly ir: ApplicationIR; readonly index: GraphIndex },
) => ReadonlyArray<EndpointFragmentPart>;
/**
* Output policy never enters ApplicationIR or its semantic hash.
*
* @internal
*/
export interface GenerationContext {
readonly target: TargetProfile;
readonly emit: EmitMode;
readonly allowImportingTsExtensions: boolean;
readonly canonicalImportBase?: string;
readonly outputDir?: string;
/** Runtime-only target-project resolver; never part of serialized IR or project settings. */
readonly resolveEffectModule?: (specifier: string) => boolean;
/** Checker-proven concrete root groups, outside the IR and semantic hash. */
readonly httpApiGroups?: ReadonlyArray<HttpApiGroupInventory>;
readonly bindings?: ReadonlyArray<HttpGroupBinding>;
/** The extensions' endpoint fragments in extension-list order; `generate` fills it from the extensions. */
readonly fragments?: ReadonlyArray<EndpointFragment>;
}
/** @internal */
export const defaultGenerationContext: GenerationContext = {
target: "effect-4.0",
emit: "all",
allowImportingTsExtensions: false,
};
export type Generator = (
ir: ApplicationIR,
index: GraphIndex,
context?: GenerationContext,
) => Effect.Effect<ReadonlyArray<GeneratedFile>, CompilerFault>;
/** What a pre-pass leaves of `Collected.declarations`, and the diagnostics it found. */
export interface Expansion {
readonly declarations: ReadonlyArray<Declaration>;
readonly diagnostics: ReadonlyArray<Diagnostic>;
}
/**
* A cross-annotation pre-pass over the collected declarations, run before any interpreter (spec 0020 §3,
* the 0013 seam). Pure; it may rewrite annotations (source sugar becomes ordinary annotations) but never
* adds IR. `declarations` replaces the input's, in the order returned.
*/
export type Expand = (collected: Collected) => Expansion;
/**
* One definition-owned compiler lift entry an `extension()` aggregates (spec 0019 §5, S1): the
* definition value the extension declares plus the erased hook `implement()` built. Subsidiary to
* `liftRegistryOf(extensions)`; not the old `Extension.lifters` proposal — the definition-owned
* `implement({ lift })` interface is the one that exists.
*/
export interface ExtensionLiftEntry {
readonly definition: DefinitionData;
readonly lift: LiftImplementation;
}
export interface Extension {
readonly name: string;
/** Entry declarations, Schema-decoded and collision-checked with the bundled catalogue before frontend analysis. */
readonly diagnosticEntries?: ReadonlyArray<DiagnosticEntry>;
/** Definitions this extension implements; their plans drive frontend lowering (spec 0020). */
readonly annotations?: ReadonlyArray<DefinitionData>;
/**
* The per-definition compiler lift entries `extension()` aggregates (spec 0019 §5, S1). Subsidiary seam
* for `liftRegistryOf(extensions)`; not the old `Extension.lifters` proposal — the definition-owned
* `implement({ lift })` interface is the one that exists.
*/
readonly lifts?: ReadonlyArray<ExtensionLiftEntry>;
readonly interpreters: Readonly<Record<string, Interpreter>>;
readonly analyses: ReadonlyArray<Analysis>;
/**
* An optional pre-pass over Collected, run in extension order. Its diagnostics (including related
* occurrences) are registry-checked before reporting; violations become EFFX0010 and block output.
*/
readonly expand?: Expand;
/**
* Endpoint fragments, in declaration order (spec 0020 §6). `extension()` derives one for every
* definition with an `effect` clause; the pipeline hands them to the generators through
* `GenerationContext.fragments`.
*/
readonly fragments?: ReadonlyArray<EndpointFragment>;
readonly generators: ReadonlyArray<Generator>;
}
Extension
| Field | Type | Meaning |
|---|---|---|
name | string | the extension name |
interpreters | Readonly<Record<string, Interpreter>> | annotation name to interpreter. The key is the annotation name, such as Http.Get. |
analyses | ReadonlyArray<Analysis> | checks over the finished IR |
generators | ReadonlyArray<Generator> | file producers |
annotations | ReadonlyArray<DefinitionData>? | typed definitions the extension implements; their lowering plans drive the frontend (spec 0020). extension(...) fills it. |
expand | Expand? | a pre-pass over the collected declarations, run before any interpreter (spec 0020, group defaults use it) |
fragments | ReadonlyArray<EndpointFragment>? | method-call suffixes appended to a generated HTTP endpoint (spec 0020). extension(...) derives one per definition with an effect clause. |
Interpreter and Contribution
Interpreter = (annotation, declaration, ctx: InterpretContext) => Contribution. An interpreter is a pure function.
| Type | Field | Type | Meaning |
|---|---|---|---|
Contribution | nodes | ReadonlyArray<Node> | IR nodes to add |
edges | ReadonlyArray<Edge> | IR edges to add | |
diagnostics | ReadonlyArray<Diagnostic> | problems found while interpreting | |
InterpretContext | operationId | Option<StableId> | the operation this declaration defines, from its Query or Command annotation, if any |
The Contribution doc comment: "What one annotation on one declaration contributes to the IR. Never behaviour." (The invariant).
Contribution also exports helpers:
| Helper | Result |
|---|---|
Contribution.empty | no nodes, edges or diagnostics |
Contribution.make(nodes?, edges?, diagnostics?) | a contribution |
Contribution.diagnostics(...diagnostics) | diagnostics only |
Contribution.concat(contributions) | one merged contribution |
Analysis
Analysis = (ir, index, context: AnalysisContext) => ReadonlyArray<Diagnostic>. It reads the normalized IR and its GraphIndex (The IR).
| Type | Field | Type | Meaning |
|---|---|---|---|
AnalysisContext | strictAccess | boolean | set by --strict-access (effx check) |
Generator
Generator = (ir, index, context?: GenerationContext) => Effect<ReadonlyArray<GeneratedFile>, CompilerFault>. If context is omitted, defaultGenerationContext applies.
| Type | Field | Type | Meaning |
|---|---|---|---|
GeneratedFile | path | string | path relative to the output directory |
contents | string | file text | |
GenerationContext | target | TargetProfile | effect-4.0 |
emit | EmitMode | contract, handlers or all | |
allowImportingTsExtensions | boolean | .ts instead of .js in relative imports | |
canonicalImportBase | string? | output-independent base for local symbols | |
outputDir | string? | where this pass writes | |
resolveEffectModule | ((specifier: string) => boolean)? | runtime-only resolver of the target project's Effect modules. It is never part of serialized IR or project settings. |
defaultGenerationContext is { target: "effect-4.0", emit: "all", allowImportingTsExtensions: false }. Output policy never enters the IR (Emit modes and targets).
How the pipeline uses extensions
compile(project, extensions) takes the extension list as an argument. Source: packages/compiler/src/pipeline.ts.
Collected ──expandGroupDefaults──▶ interpret ──▶ analyze ──▶ generate
(spec 0013) each annotation each analysis each generator
→ its owners of each ext of each ext| Stage | Rule |
|---|---|
| Interpret | For each declaration and annotation, every extension whose interpreters has that annotation name runs. Contributions merge, then normalize runs. |
| Unknown annotation | No owner at all yields error EFFX1101: "no extension interprets this annotation". |
| Several owners | All owners run. Their contributions merge. |
| Location | A diagnostic from an interpreter that has no location gets the declaration's location. |
| Analyze | Every analysis of every extension runs over the normalized IR and its graph index. |
| Generate | Skipped if any diagnostic has severity error. Otherwise every generator runs. Files are sorted by path. |
Before interpretation, expandGroupDefaults (spec 0013) applies group defaults and consumes Http.In. Http.In is never interpreted or persisted (packages/compiler/src/group-defaults.ts).
Where extensions are registered
compile(project, extensions) takes the list as an argument. The CLI builds that list from Extensions.builtin (packages/compiler/src/extensions/index.ts) plus the extensions field of the selected effx.config.ts: an array appends, a callback receives the built-ins and returns the full ordered list (spec 0015, packages/cli/src/config.ts). See Writing an extension.
Extensions is re-exported from @effx/compiler, with operationIdOf and each built-in extension value.
Built-in extensions
Extensions.builtin lists them in this order. Files are in packages/compiler/src/extensions/.
| # | name | Interprets | Analyses | Generators |
|---|---|---|---|---|
| 1 | core | Query, Command, Errors, Requirements, PersistentModel, Authorize | duplicateIds, missingTargets, orphanExtensions, invalidOperationBinding, externalExecutableProjection, queryOverNonGet, inferredOpaqueWarning | none |
| 2 | http | Http.Get, Http.Post, Http.Put, Http.Patch, Http.Delete | none | httpGenerator, guardsGenerator |
| 3 | foldkit | Foldkit.Command | analyzeCommands | foldkitGenerator |
| 4 | http-contract | Http.Contract | validate | none |
| 5 | http-group | Http.Group | validate, validateExportNames | none |
| 6 | access-contract | Http.Access | analyzeAccess | none |
| 7 | problem-contract | Http.Problems | analyzeProblems | none |
| 8 | rpc | Rpc | none | rpcGenerator |
| 9 | cli | Cli | none | cliGenerator |
| 10 | client | none | analyzeRootVisibility | clientGenerator |
Source: the Extension value at the end of each file, and packages/compiler/src/extensions/{core,transports}.ts. The http, rpc and cli extensions share one transport interpreter that adds an Exposure node and an ExposedAs edge.
The core extension owns the Operation node. Its Errors and Requirements interpreters compare declared and inferred sets, so those diagnostics appear during interpretation, not analysis (spec 0001, ADR 0005).
How the pieces become files
| Generator | File written |
|---|---|
httpGenerator | http.ts, and <group>-contract.ts and <group>-handlers.ts for HTTP groups |
guardsGenerator | guards.ts |
rpcGenerator | rpc.ts |
cliGenerator | cli.ts |
clientGenerator | client.ts |
foldkitGenerator | foldkit.ts |
See Generated code for when each is written.
Third-party concepts in the IR
A new concept does not need a core schema change. It uses an Extension node (ext:<extension>/<name>) with data: Json, connected to its owner by an ExtensionOf edge. The built-in contract, access and problem extensions store their data this way. See The IR.
Status
| Item | Status |
|---|---|
Extension contract and the ten built-ins | implemented. STATE.md: kernel plus built-in extensions and generators landed. |
Registering an extension from effx.config.ts | implemented (spec 0015); tested by examples/extension-openapi-tags |
| Authoring guide for third-party extensions | Writing an extension |
Typed annotation definitions (Annotation.define, implement, extension) | implemented (spec 0020); registered through the same effx.config.ts. See Writing an extension |
| SQL persistence projection | proposed only, parked (docs/specs/0008-sql-projection-extension.md) |
Schema refs
effx keeps Effect Schemas and symbols by reference. SchemaRef and SymbolRef name an export. Generated code imports the original value.
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.