effx
Extensions

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:

packages/compiler/src/Extension.ts
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

FieldTypeMeaning
namestringthe extension name
interpretersReadonly<Record<string, Interpreter>>annotation name to interpreter. The key is the annotation name, such as Http.Get.
analysesReadonlyArray<Analysis>checks over the finished IR
generatorsReadonlyArray<Generator>file producers
annotationsReadonlyArray<DefinitionData>?typed definitions the extension implements; their lowering plans drive the frontend (spec 0020). extension(...) fills it.
expandExpand?a pre-pass over the collected declarations, run before any interpreter (spec 0020, group defaults use it)
fragmentsReadonlyArray<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.

TypeFieldTypeMeaning
ContributionnodesReadonlyArray<Node>IR nodes to add
edgesReadonlyArray<Edge>IR edges to add
diagnosticsReadonlyArray<Diagnostic>problems found while interpreting
InterpretContextoperationIdOption<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:

HelperResult
Contribution.emptyno 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).

TypeFieldTypeMeaning
AnalysisContextstrictAccessbooleanset by --strict-access (effx check)

Generator

Generator = (ir, index, context?: GenerationContext) => Effect<ReadonlyArray<GeneratedFile>, CompilerFault>. If context is omitted, defaultGenerationContext applies.

TypeFieldTypeMeaning
GeneratedFilepathstringpath relative to the output directory
contentsstringfile text
GenerationContexttargetTargetProfileeffect-4.0
emitEmitModecontract, handlers or all
allowImportingTsExtensionsboolean.ts instead of .js in relative imports
canonicalImportBasestring?output-independent base for local symbols
outputDirstring?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
StageRule
InterpretFor each declaration and annotation, every extension whose interpreters has that annotation name runs. Contributions merge, then normalize runs.
Unknown annotationNo owner at all yields error EFFX1101: "no extension interprets this annotation".
Several ownersAll owners run. Their contributions merge.
LocationA diagnostic from an interpreter that has no location gets the declaration's location.
AnalyzeEvery analysis of every extension runs over the normalized IR and its graph index.
GenerateSkipped 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/.

#nameInterpretsAnalysesGenerators
1coreQuery, Command, Errors, Requirements, PersistentModel, AuthorizeduplicateIds, missingTargets, orphanExtensions, invalidOperationBinding, externalExecutableProjection, queryOverNonGet, inferredOpaqueWarningnone
2httpHttp.Get, Http.Post, Http.Put, Http.Patch, Http.DeletenonehttpGenerator, guardsGenerator
3foldkitFoldkit.CommandanalyzeCommandsfoldkitGenerator
4http-contractHttp.Contractvalidatenone
5http-groupHttp.Groupvalidate, validateExportNamesnone
6access-contractHttp.AccessanalyzeAccessnone
7problem-contractHttp.ProblemsanalyzeProblemsnone
8rpcRpcnonerpcGenerator
9cliClinonecliGenerator
10clientnoneanalyzeRootVisibilityclientGenerator

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

GeneratorFile written
httpGeneratorhttp.ts, and <group>-contract.ts and <group>-handlers.ts for HTTP groups
guardsGeneratorguards.ts
rpcGeneratorrpc.ts
cliGeneratorcli.ts
clientGeneratorclient.ts
foldkitGeneratorfoldkit.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

ItemStatus
Extension contract and the ten built-insimplemented. STATE.md: kernel plus built-in extensions and generators landed.
Registering an extension from effx.config.tsimplemented (spec 0015); tested by examples/extension-openapi-tags
Authoring guide for third-party extensionsWriting 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 projectionproposed only, parked (docs/specs/0008-sql-projection-extension.md)

On this page