@xndrjs/forma

中文文档
0.1.0-alpha.2发布于 2026/11/03MITTypeScriptES Module零依赖

Intent-oriented composition for TypeScript: boundaries, intents, providers, and closed compose graphs.

原始使用文档
文档目录9 节
  1. Installation
  2. Quick start
  3. Boundary, Intent, contract
  4. Providers
  5. Adapter
  6. Compose and execution context
  7. Stream Intent
  8. Validate the published package
  9. Status

@xndrjs/forma

Intent-oriented composition for TypeScript: declare boundaries and intents, attach providers, and wire a closed graph with compose. The composition owns the public surface, lazy materialization, and execution-context lifetimes.

This package is a proof of concept. Vocabulary lives in GLOSSARY.md; design records live under docs/adr/ (Adapter peer of Boundary; unified wiring registration; Intent alias / Adapter passthrough default; surface/using key hygiene; Design C execution API; execution params / AbortSignal; effective lifetime inference; Stream Intent modality / ownership — ADR 0012; Entry Boundary and Surface direction — ADR 0013; nominal Providers and runtime branding — ADR 0014; Boundary error totalization — ADR 0015; optional defineWiring when extracting wiring).

Installation

npm install @xndrjs/forma

Quick start

Boundary, Intent, contract

import { boundary } from "@xndrjs/forma";

type OrderId = { readonly id: string };
type Order = { readonly id: string; readonly total: number };
type FindError = { readonly kind: "not_found" };

const Orders = boundary("orders");

export const FindOrder = Orders.intent("find").contract<OrderId, Order, FindError>();
export const GetOrder = Orders.intent("get").contract<OrderId, Order, FindError>();

Name first, then .contract<Input, Success, Err>() — TypeScript keeps the literal Intent name that way. boundary.intent is always unary (one terminal InvocationOutcome). For pull-based sequences use boundary.stream (below). Every Boundary Intent exit is totalized: implementation throw/rejection becomes unexpected at the origin (ADR 0015).

Providers

.with takes a factory that returns the Intent operation as a function (sync or async). Prefer ctx => function (…) { … } over nested arrows. Destructure context only when you use it. Lifetime.onDispose is valid only during that materialization attempt — after the factory returns, the handle is sealed; on failure Forma rolls back local cleanups immediately and does not cache the attempt.

The resulting Provider is an immutable nominal descriptor. Its public metadata is intent, deps, origin, and lifetimePolicy; realization and Params inference machinery remain internal. Use compose to exercise Provider behavior—Provider factories are not a public invocation or monkey-patching seam.

import { expectedError, success } from "@xndrjs/forma";

const FindProvider = FindOrder.provide.with(
  (_ctx) =>
    function (id) {
      return id.id === "42"
        ? success({ id: "42", total: 100 })
        : expectedError({ kind: "not_found" as const });
    }
);

type ConnHandle = { close(): void };
async function openConnection(opts: { signal: AbortSignal }): Promise<ConnHandle> {
  opts.signal.throwIfAborted();
  return { close() {} };
}

const Conn = Orders.intent("conn").contract<void, ConnHandle, never>();
const ConnProvider = Conn.provide.perContext().with(async ({ lifetime }) => {
  const conn = await openConnection({ signal: lifetime.signal });
  lifetime.onDispose(() => conn.close());
  return function () {
    return success(conn);
  };
});

type OrderParams = { readonly role: "admin" | "viewer" | "guest" };

const GetOrderProvider = GetOrder.provide
  .perContext<OrderParams>()
  .using({ find: FindOrder })
  .with(
    ({ deps: { find }, params }) =>
      function (id) {
        if (params.role === "guest") {
          return expectedError({ kind: "not_found" as const });
        }
        return find(id); // forwards success / error / unexpected / cancelled
      }
  );

Lifetime policy is local, while effective lifetime is derived from the resolved graph. No modifier means auto: the Provider is shared by the composition root unless any transitive dependency is context-owned, in which case it is narrowed to one instance per openExecutionContext() / withExecutionContext(). .perContext() forces context ownership; .perComposition() pins root ownership and makes compose() fail with the dependency path if a transitive dependency requires a context. root.describePlan() reports declared and effective lifetimes. Root and context dispose() are terminal state machines (concurrent callers share one promise); entering disposing aborts that owner’s AbortSignal (lifetime.signal / execution.signal). root.dispose() also closes any open execution contexts. Prefer withExecutionContext (or await using on the open handle) for short lexical scopes; use openExecutionContext when a host owns the lifetime across mount/unmount.

Typed execution params: declare a plain outer object bag on .perContext<ParamsReq>(). compose({ … }) infers the required host bag from all such declarations (merge; conflicting key types fail at compose). open/with then require { params } (shared ExecutionContextArgs). At runtime Forma accepts only an Object.prototype/null-prototype bag with own enumerable string data properties, clones it, and shallow-freezes the copy; nested values are not validated or frozen. The execution handle and all per-context factories receive that same copy. Factories read typed params on the .with context — not on Lifetime. compose<Explicit>()({ … }) is only for an explicit superset of the inferred bag and applies the same best-effort static shape check. An optional host signal on those args links into the context signal without transferring dispose ownership.

Intent.provide.using accepts only same-boundary Intents, is one-shot (do not chain .using), and requires string dependency keys. Surface aliases are likewise string-only; dispose and surface are reserved on the composition surface map (operations nest under execution.surface). Every Provider carries a required origin discriminant: { kind: "boundary", id } from Intent.provide…with, or { kind: "adapter", id } from Adapter .adapt…with.

Adapter

An Adapter is a peer of Boundary, scoped to one target Boundary. It owns external realizations of that target’s Intents and may using Intents from any boundary. Default for cross-boundary wiring — including when contracts match — is an Adapter (or same-boundary Provider) passthrough, not an identity alias. That keeps origin, graph edges, lifetime, and room for policy explicit.

import { adapter, expectedError, success } from "@xndrjs/forma";

type PgOrderId = { readonly order_id: string };
type PgOrder = { readonly order_id: string; readonly amount_cents: number };
type PgFindError = { readonly code: "NO_ROW" };

const Postgres = boundary("postgres");
const SelectOrder = Postgres.intent("selectOrder").contract<PgOrderId, PgOrder, PgFindError>();

const OrdersAdapter = adapter("orders-postgres", Orders);

export const FindOrderViaPostgresProvider = OrdersAdapter.adapt(FindOrder)
  .using({ select: SelectOrder })
  .with(
    ({ deps: { select } }) =>
      async function (id) {
        const row = await select({ order_id: id.id });
        switch (row.kind) {
          case "success":
            return success({
              id: row.value.order_id,
              total: row.value.amount_cents / 100,
            });
          case "error":
            return expectedError({ kind: "not_found" as const });
          case "unexpected":
          case "cancelled":
            return row; // pass through unchanged — never reinterpret as expected Err
        }
      }
  );

.adapt rejects Intents whose BoundaryId does not match the Adapter’s target (compile-time). The result is a Provider registerable in private wiring, with origin: { kind: "adapter", id: "orders-postgres" }. Adapter Providers cannot be Surface roots. Map only success / error; pass unexpected / cancelled through. Do not wrap Forma dependency calls in try/catch for implementation failure — the dependency Intent is already totalized.

When shapes already match, the factory can forward without mapping (return select(input)). Reserve aliasIntent(required, satisfier) for deliberate synonyms (rename / migration). There is no forward helper yet.

Compose and execution context

Prefer an inline compose({ entryBoundaries, surface, wiring }) literal. Entry Boundaries are the exact, non-empty set of authentic boundary() values whose origins are exposed by the Surface. Every declared Entry Boundary must own at least one Surface alias, every Surface root must come from one of them, and Adapter roots are rejected. Runtime branding validates protocol provenance, not security. This keeps public graph direction and Provider precision explicit — you do not need defineWiring in the normal case.

import { compose, type InvocationOutcome } from "@xndrjs/forma";

const SelectOrderProvider = SelectOrder.provide.with(
  (_ctx) =>
    function (id) {
      return id.order_id === "42"
        ? success({ order_id: "42", amount_cents: 10000 })
        : expectedError({ code: "NO_ROW" as const });
    }
);

const root = compose({
  entryBoundaries: [Orders],
  surface: { getOrder: GetOrderProvider },
  wiring: [SelectOrderProvider, ConnProvider, FindOrderViaPostgresProvider],
  // Optional: only automatic throw→unexpected conversions (once at origin).
  onUnexpectedFailure(event) {
    console.error(event.failure.id, event.cause, event.intent, event.phase);
  },
});

const result: InvocationOutcome<Order, FindError> = await root.withExecutionContext(
  async (surface) => {
    return surface.getOrder({ id: "42" });
  }
);

await root.dispose();

defineWiring / defineComposition exist only if you extract wiring (or the whole options bag) into a variable or module: a plain const wiring = [A, B] widens to Provider[] and type-checks as if private providers were missing. Inline (or as const / defineWiring) keeps the tuple precise. Do not annotate a concrete config as ComposeOptions — that widens Entry Boundaries, Surface, and Wiring and prevents exact provenance/graph checks. Provider unions in one surface or wiring slot are rejected.

Host-owned lifetime (no callback), with params (inferred from GetOrderProvider’s ParamsReq; use compose<Superset>() only to widen):

const rooted = compose({
  entryBoundaries: [Orders],
  surface: { getOrder: GetOrderProvider },
  wiring: [SelectOrderProvider, ConnProvider, FindOrderViaPostgresProvider],
});

await using execution = rooted.openExecutionContext({ params: { role: "viewer" } });
await execution.surface.getOrder({ id: "42" });

Only surface aliases declared in compose({ entryBoundaries, surface }) are callable, nested on execution.surface (lifecycle stays on the handle). Intent identity is the structured pair (boundaryId, intentName) — competing realizations of the same pair throw at compose time. Repeated declarations of that identity must have exactly equivalent Input, Success/Item, Err, and modality on every resolved using edge; runtime additionally rejects reified modality mismatches during compose. compose also builds a dependency plan (using + Intent-alias edges), rejects cycles with a full path diagnostic, requires a closed graph, and derives effective lifetimes before returning the root. describePlan() includes Entry Boundary ids, Surface root provenance, Boundary/Adapter origin, and a nested lifetime explanation per Provider. Private providers and aliasIntent() entries share one wiring array; prefer Adapter passthrough for cross-boundary wiring. See apps/forma-demo for a dual-Adapter end-to-end example (postgres | memory, params policy, WatchOrders entry stream delegating to a private WatchOrderEvents seam, async connection, inferred lifetimes, await using root).

Stream Intent

Declare a stream Contract with boundary.stream(name).contract<Input, Item, Err>(). Same .provide / .adapt builders; the factory returns a StreamOperation (async function* or any AsyncIterable producer) that receives (input, invocation) where invocation.signal is per call and subordinate to the execution-context signal.

type OrderEvent = { readonly orderId: string; readonly status: string };
type WatchError = { readonly kind: "unavailable" };

const WatchOrders = Orders.stream("watchOrders").contract<OrderId, OrderEvent, WatchError>();

const WatchOrdersProvider = WatchOrders.provide.with(
  () =>
    async function* (id, invocation) {
      try {
        invocation.signal.throwIfAborted();
        yield success({ orderId: id.id, status: "open" });
        yield success({ orderId: id.id, status: "paid" });
      } finally {
        // runs on natural end, break, return(), AsyncDisposable, or context/root dispose
      }
    }
);

const streamingRoot = compose({
  entryBoundaries: [Orders],
  surface: { watchOrders: WatchOrdersProvider },
});

await using execution = streamingRoot.openExecutionContext();
for await (const event of execution.surface.watchOrders({ id: "42" })) {
  if (event.kind !== "success") break; // error / unexpected / cancelled are terminal
  // …
}

Ownership and cancellation (see streaming-intents.md, ADR 0012):

  • Each surface call returns a cold, single-consumer AsyncIterable (+ [Symbol.asyncDispose]). Creating it does not materialize the Provider; the first next() does.
  • Every active stream invocation is owned by the execution context (lease + registry), independent of Provider effective lifetime — including streams from composition-effective Providers.
  • First next() acquires registry/lease ownership transactionally before scheduling the source. If acceptance fails, the source is not started and partial ownership is rolled back through the normal close/error matrix.
  • break / return() / await using on the iterable, context dispose, and root dispose close active streams (abort per-stream signal → return() → await finally → release-once). Cancelling one stream does not abort siblings or the context.
  • Opening with an already-aborted host signal fails immediately. A later host abort rejects new unary work with signal.reason, and a cold stream that has not started completes without materialization. Already-accepted work observes cancellation cooperatively. Host abort does not auto-dispose() the context (abort ≠ dispose).
  • Stream cleanup failures are observed by the caller that initiates close. Context/root disposal still attempts every stream and Provider cleanup and aggregates multiple failures.
  • Adapter preserves modality; unary↔stream Intent alias fails at compile time. using a Stream Intent resolves to the StreamOperation, never an open iterator. describePlan() reports mode: "unary" | "stream" on every registered Intent.

Validate the published package

From the repository root, run pnpm validate:forma-package. The command builds Forma, creates the same tarball shape published to npm, validates its exports and declarations with publint and Are The Types Wrong, installs it into an isolated consumer, typechecks that consumer with skipLibCheck: false, and runs a unary/stream/disposal smoke test. It never resolves @xndrjs/forma through the workspace source alias.

Status

PoC (0.1.0-alpha.0): closed compositions, .contract, Adapter peer of Boundary, rare structural aliasIntent, graph-derived effective lifetimes, unified .with({ deps?, lifetime }), Design C execution API, async factories, Stream Intent modality, Boundary error totalization (InvocationOutcome), disposal. Not a general-purpose IoC container.

版本与发布记录

@xndrjs/forma 共发布 4 个版本,首次发布于 2026/10/27;过去 12 个月发布了 4 个版本。

版本发布日期距今
0.1.0-alpha.2latest2026/11/03今天
0.1.0-alpha.12026/10/312 天前
0.1.0-alpha.02026/10/277 天前
0.0.0-stage2026/10/277 天前

关于 @xndrjs/forma 的常见问题

@xndrjs/forma 是什么?

TypeScript面向意图的组合:边界、意图、提供者和封闭组合图。

如何安装 @xndrjs/forma?

在项目目录运行 npm install @xndrjs/forma,或使用 pnpm add / yarn add / bun add。

@xndrjs/forma 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@xndrjs/forma 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@xndrjs/forma 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24 <26。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@xndrjs/forma 可以商用吗?

@xndrjs/forma 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@xndrjs/forma 还在维护吗?

最新版本 0.1.0-alpha.2 发布于 2026/11/03(今天);至今共发布 4 个版本,其中过去 12 个月发布了 4 个;首次发布于 2026/10/27。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。