Static() — Capability Classes
Applications are not made only of stateful models. Around them lives a second population: capability classes — stateless bags of functions for the file system, git commands, parsers, clipboards, clocks. They hold no instance state, are never constructed, and exist to be called — and swapped: by a test double, a plugin, a platform variant.
Static() is the transform for that population — the static-side sibling of Reactive(). It ships from the separate ivue/extras entry so the primary ivue entry stays the bare engine:
import { Static } from 'ivue/extras';Invar, the terminal IDE built by AI agents on ivue, runs its entire capability layer — files, git, parsers, clipboard, status — on this one seam shape, applied uniformly across the codebase.
The shape
// GitCommands.ts
import { Static } from 'ivue/extras';
class $GitCommands {
// a LIVE knob — subclasses pinch it, so NO $ prefix
static get binary() {
return 'git';
}
// derived ONCE per receiving class — the $ prefix is the API
static get $environment() {
return { GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
}
// methods bind lazily to the receiving class — detachable, stable
static stage(path: string) {
return this.run(['add', '--', path]);
}
static run(argumentList: readonly string[]) {
return execute(this.binary, argumentList, this.$environment);
}
}
export namespace GitCommands {
// the ANCHOR: wrapped once, at definition — children `extends` this
export const $Class = Static($GitCommands);
export let Class = $Class; // selected — you call this; kernels/tests swap it
}The rule: wrap at the anchor. A class that declares static members publishes a Static()-wrapped $Class, so everything downstream — subclasses, test doubles, plugin extensions — inherits working semantics just by extending it. Classes with nothing static to transform skip the wrapper entirely ($Class = $X), which is why Static() lives in ivue/extras and costs the core path nothing.
Consumers call through the namespace — GitCommands.Class.stage(path) — and because Class is a mutable binding read late, the whole capability stays replaceable: a kernel, a plugin, or a test installs a different class and every call site follows, with no dependency-object threading.
Static() returns a subclass of the raw class — the raw declaration is never touched. The wrapped subclass is the anchor: const, so inheritance always pins one immutable foundation regardless of what a kernel or test later installs into the mutable Class slot.
Methods: bound lazily, stable forever
Class.method is the same function on every read, bound to the receiving class — safe to detach, hand to a router, keep in a registry:
const stage = GitCommands.Class.stage;
stage('src/index.ts'); // `this` intact — dispatches through the classBinding is per receiver: a subclass that overrides binary gets methods bound to itself, so this.binary inside run() sees the override — in any read order. (The bound function is cached under a symbol own-property of the receiver, never under the method name, so a parent's cache can never shadow a subclass through the prototype chain.)
$-cached static getters
A get-only static accessor whose name starts with $ becomes a compute-once-per-receiver cache: the getter body runs on the first read through a given class, and every later read returns the same value.
The $ prefix promises stable identity per receiver — nothing more. Whether the cached value is immutable configuration or a deliberately mutable memo table is your design; the engine does not freeze it:
class $LineWrap {
// an immutable config table — computed once, read forever
static get $defaults() {
return { column: 80, hangingIndent: 2 };
}
// a MUTABLE memo table — also a legitimate $-cache: the cache is
// the table's IDENTITY, and the table's contents are yours to manage
static get $widthMemo() {
return new Map<string, number>();
}
}Two rules keep the population honest:
- Knobs are not caches. A static getter that must stay live — a parameter a test subclass pinches, a value that must be fresh per read — must not use the
$prefix. The prefix is the author's explicit compute-once marker, exactly as it is for instance$-getters inReactive(). - Caching is per receiver. When a subclass overrides an input,
Sub.$xderives through the override andSub.$x !== Base.$x. Compare by value, or through one receiver.
Hot paths: hoist the cache once
A warm $-cache read costs a few nanoseconds more than a plain property (an own-property guard is the price of the per-receiver correctness above — measured at ~4–6 ns on Node 26). At any ordinary call frequency that is invisible. In a genuinely hot loop, apply the same discipline as hot ref reads: hoist the value into a local once, outside the loop:
const defaults = LineWrap.Class.$defaults; // one guarded read
for (const line of millionLines) {
wrap(line, defaults); // plain local access from here on
}One hoist line converts the per-read cost to zero for the whole algorithm — explicitly, at the call site, with no correctness trade.
Testing: the seam is the harness
Capability classes need no mock framework — the namespace publishes both exports a test needs:
// pinch one knob — NO wrapper needed: the anchor's semantics are
// inherited, and the cache derives through the override per receiver
class SandboxGit extends GitCommands.$Class {
static override get binary() {
return '/opt/sandbox/git';
}
}
// swap the whole capability at the seam — the double OVERRIDES a
// method (a new declaration), so it is wrapped where it is installed
class $RecordingGit extends GitCommands.$Class {
static override run(argumentList: readonly string[]) {
return record(argumentList);
}
}
GitCommands.Class = Static($RecordingGit);Because the anchor is already wrapped, the common double — pinching a live knob — needs nothing at all: inherited methods bind to the double, inherited $-caches derive through its overrides, in any read order. Only a double that declares or overrides transformable members (new methods, new $-getters) passes through Static() itself — new declarations always need processing, and the seam-swap line is where that naturally happens. Nothing ever extends the mutable Class slot: an extends clause is an eager snapshot, so it would pin the child to whatever generation a kernel or the previous test had installed — inheritance silently ordered by load order, the disease this pattern exists to cure.
The substitute is a full citizen, type-checked against the same contract as the real thing — stub drift is a compile error. The full doctrine, with its third strength (planting defects as positive controls), is The test is a subclass.
The backend: Node capability classes
Nothing above is Vue-specific — Static() imports no Vue and runs anywhere JavaScript does. On a Node backend, where most modules are function bags, the same shape becomes the default module grammar. Two additions complete it.
Dependency getters read the live slot. A capability reaches another capability through a static getter, so the cross-module read happens when behavior runs — never while the module graph is initializing. Imports may be circular; nothing reads eagerly:
class $Orders {
static get Users() {
return Users.Class; // late — and always the SELECTED class
}
static submit(request: OrderRequest) {
return this.Users.find(request.params.userId);
}
}Retained callbacks are safe — and belong to one generation. Because methods bind lazily with stable identity, direct registration works:
router.post('/orders', Orders.Class.submit);The router receives one stable function whose this is the class selected at registration time. A later Class assignment does not rewrite a callback already stored inside the router — so boot composition finishes before callbacks escape:
load modules → compose raw classes → select each Static() class
→ register retained callbacks → listenClass is mutable as a composition slot, not a runtime feature flag: a kernel starts from $Class, applies extensions, and calls Static() once on the composed result. Changing the plugin set restarts the process and produces a new sealed generation — why the pattern deliberately stops before a custom reload runtime is Node Development by Restart.
One privacy rule. Avoid this.#member in static capability classes: native static #private brands only its declaring class, and the selected class is a subclass, so polymorphic access rejects it. Use TypeScript private static for polymorphic encapsulation (an authoring-time rule — the selected subclass dispatches fine), protected static for extension points, and module scope for state that must be private at runtime.
One boundary carries over unchanged: a namespace slot is one application-wide answer. When a provider varies by request, tenant, or session, pass that context explicitly or use scoped DI — the pattern replaces the container for global selection only.
Composing with Reactive()
$ semantics are granted by the transform — a raw class or a class only passed through Reactive() keeps native static-getter behavior, exactly as an unwrapped class's instance $-getters aren't cached either. A class that needs instance reactivity and static $-caches uses the same anchor rule — wrap the statics at the anchor, apply Reactive() to it:
export namespace Settings {
// the anchor: statics wrapped once, at definition
export const $Class = Static($Settings);
// Reactive() transforms the prototype in place — Class === $Class
export let Class = Reactive($Class);
export type Instance = typeof Class.Instance;
}Instances of Class carry full reactive semantics (the prototype chain is transformed in place, teardown included); the static surface carries binding and $-caching; and children extending $Class inherit both worlds' foundations.
The boundary
- Stateful classes belong to
Reactive().Static()operates on the single class object and has no instance dimension — never wrap a class whose purpose is to be constructed. - Accessor pairs with a setter and non-
$getters are untouched — they are your live surface. - The exact per-member contract lives in the API reference.