The Standard Operating Manual
This page ships to AI coding agents verbatim, as the /ivue skill — .claude/skills/ivue/SKILL.md — because the instructions that make an agent write correct ivue turn out to be exactly the reference a human wants open in a second tab. Everything here is production-proven; the why behind each rule lives in the guide chapters.
Install it as a skill
One command copies this exact document into your project, version-locked to the ivue you have installed — agents pick it up from .claude/skills/ivue/:
npm install ivue # first — the CLI ships inside the package
npx ivue skill # Claude Code — .claude/skills/ivue/
npx ivue skill --all # + every agent whose footprint exists in the repoThe content is identical for every agent — only the discovery format differs. --all detects what you use (.cursor/, .github/, AGENTS.md) and never scaffolds a tool you don't; --cursor, --copilot and --agents (alias --codex — Codex CLI, Windsurf and Gemini CLI all read AGENTS.md) install their target explicitly. Evaluating before adopting? npx degit infinite-system/ivue/.claude/skills/ivue .claude/skills/ivue grabs the latest from the repo instead.
The architecture this manual encodes — every scope memoized, with polymorphism, inheritance, and performance intact — is argued in narrative form, full code included, in Bulletproof class modules. The blog is an extension of these docs, not a side channel.
ivue Reactive
Author reactive Vue 3 logic as a plain class $X, then export Class = Reactive($Class) through namespace X. The engine transforms the prototype once: ref-returning getters become cached Refs/Computeds, plain getters de-optimize to native getters (reactive via leaf tracking), methods become stable bound functions. Instances stay plain objects. Follow the rules below exactly — every deviation is either a compile error or a silent no-op at runtime.
The manual reads in three parts: the Reactive() instance world (the class and SFC templates, ownership, typing, watches, stores, keyed state), the static world (Static(), shared stores, and reading your own statics — everything from ivue/extras), and the style contract (naming, spacing, the self-review checklist).
Setup — ivue must be installed
import { Reactive } from 'ivue' resolves only when the package is a dependency. Before writing ivue code, check package.json for ivue; if it is missing, install it with the project's package manager:
npm install ivue # or: yarn add ivue / pnpm add ivue / bun add ivueSome apps vendor the engine instead — a local module such as src/utils/ivue.ts re-exporting Reactive. If one exists, import from that path and skip the install; never add the dependency alongside a vendored copy.
The class template (copy this shape)
import { Reactive } from 'ivue'; // in this app: 'src/utils/ivue'
import {
ref,
shallowRef,
computed,
watch,
onMounted,
toRef,
type Ref,
} from 'vue';
import { useProjectStore } from 'src/stores/project.store';
class $Box {
// Constructor runs SYNCHRONOUSLY where you `new` — in setup() that
// means the constructor body IS setup code, and the whole toolbox
// works here:
// - plain watch/watchEffect land in the COMPONENT's scope (reaped
// on unmount);
// - lifecycle hooks (onMounted, onUnmounted, …) register against
// the mounting component — full lifecycle access, zero wiring;
// - callbacks delegate to methods (the thin-closure rule).
// (this.$watch is ONLY for instances that OUTLIVE the component —
// see the singleton variant below. Lifecycle hooks NEVER belong in
// those.)
constructor(
public props: BoxProps,
public emit: BoxEmits,
) {
watch(
() => this.height.value,
(height, oldHeight) => this.onResize(height, oldHeight),
);
onMounted(() => this.focusBox());
}
// MUTABLE STATE — getter returning ref()/shallowRef(). `this` is
// RAW: read AND write via .value. shallowRef for big structures you
// REPLACE wholesale.
get height() {
return ref(4);
}
get rows() {
return shallowRef<Row[]>([]);
} // deep mutations do NOT trigger
// TEMPLATE-REF TARGET — a ref(null); the SFC destructures it for
// ref="boxEl".
get boxEl() {
return ref<HTMLElement | null>(null);
}
// PROPS Pattern — plain getters, one per prop the class consumes.
// Reactively tracked through the props proxy (leaf tracking).
get width() {
return this.props.width;
}
get title() {
return this.props.title;
}
get isDisabled() {
return this.props.disabled;
}
get items() {
return toRef(() => this.props.items);
} // when you need a ref handle
// The pattern's extra capability: refine the SUPPLIED prop into
// the prop the template actually needs — mixing other props, state,
// and constants, all still leaf-tracked. The template reads the
// refinement, never the raw prop; the prop is an INPUT to the
// model, not wired to the view.
get displayTitle() {
return this.title || `Box ${this.width}×${this.height.value}`;
}
// DERIVED — PLAIN getter, NO computed().
// Reactive via leaf tracking; 0 bytes/instance.
get area() {
// prop × ref — both leaf-tracked
return this.width * this.height.value;
}
get widthPx() {
return this.width + 'px';
}
// computed() — SURGICAL opt-in only: expensive work,
// render-suppression by value-equality, or a stable ref handle for
// watch/props (~300 bytes/instance). THIN closures (see "computed()
// and watch callbacks delegate to methods"): the computed only
// dials a method — logic stays on the prototype, directly testable,
// minimum footprint.
get sortedRows() {
return computed(() => this.sortRows());
}
get celsius() {
return ref(20);
}
get fahrenheit() {
return computed({
get: () => this.celsiusToFahrenheit(),
set: (fahrenheit: number) => this.setFromFahrenheit(fahrenheit),
}); // writable computed — the only way to give a COMPUTED a setter.
// A native `get x() / set x(value)` accessor pair works too;
// pick the computed form when the member must be a ref handle
// (v-model target, watch source, destructured state binding).
}
// STORE / COMPOSABLE — `$`-getter caches WHOLE, forever, per
// instance. Resolves on first touch (after Pinia/app ready);
// circular-import safe.
protected get $project() {
return useProjectStore();
}
get projectId() {
return this.$project.projectId;
}
// METHODS — plain; engine-binds to raw (stable identity, safe as
// handlers). Reactive-closure bodies above delegate HERE (the
// thin-closure rule).
grow() {
this.height.value++;
}
focusBox() {
this.boxEl.value?.focus();
}
sortRows() {
return [...this.rows.value].sort(byScore);
}
celsiusToFahrenheit() {
return (this.celsius.value * 9) / 5 + 32;
}
setFromFahrenheit(fahrenheit: number) {
this.celsius.value = ((fahrenheit - 32) * 5) / 9;
}
onResize(height: number, oldHeight: number) {
/* ... */
}
}
export namespace Box {
export const $Class = $Box; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
// the type of every unwrapping surface (defineExpose, reactive())
export type Instance = typeof Class.Instance;
}A class with NO static members exports exactly this shape. Only a class that DECLARES statics anchors them — export const $Class = Static($Box) — and reads them from instance code through self; both live in the static-world sections below.
The optional Model line (domain entity graphs)
When classes hold and pass RAW instances of each other — entity collections, method parameters, factory returns — the namespace grows a fourth line:
export namespace Task {
export const $Class = $Task;
export let Class = Reactive($Class);
// raw-instance type — collections, parameters, returns
export type Model = InstanceType<typeof Class>;
// the type of every unwrapping surface (defineExpose, reactive())
export type Instance = typeof Class.Instance;
}Model is the raw-instance type (Refs stay Refs; .value access) — use it for shallowRef<Task.Model[]> collections and workloadPercent(member: Member.Model) parameters. Instance remains ONLY for unwrapping surfaces (defineExpose, reactive(), template refs); never type a raw collection with it.
The SFC wiring template (copy this shape)
<script lang="ts" setup>
import { Box } from './Box';
const props = withDefaults(defineProps<BoxProps>(), { width: 400 });
const emit = defineEmits<BoxEmits>();
// ONE raw instance — the same object drives template, emits
// payloads, and expose. No reactive() wrapper, no unwrap view. The
// constructor runs init in setup context.
const box = new Box.Class(props, emit);
// THE STATE DESTRUCTURE — one statement, grouped. Every Ref/Computed
// the template touches is listed here; each binding IS the cached
// cell (stable identity), and setup bindings unwrap uniformly in
// EVERY template position. NEVER destructure plain getters or
// methods (snapshots a dead value).
const {
// state refs
height,
celsius,
// computed refs
sortedRows,
fahrenheit,
// element refs
boxEl,
} = box;
// Type the expose surface through Instance — it strips readonly so
// ref-writes typecheck.
defineExpose(box as Box.Instance);
</script>
<template>
<!-- State bindings — reads AND writes compiler-unwrapped.
fahrenheit is the writable computed: v-model writes through
its setter. -->
<input
ref="boxEl"
v-model.number="fahrenheit"
:disabled="box.isDisabled"
/>
<div v-if="height > 4">
{{ box.displayTitle }} — {{ celsius }}°C is {{ fahrenheit }}°F
</div>
<ul :style="{ width: box.widthPx }">
<li v-for="row in sortedRows" :key="row.id">{{ row.name }}</li>
</ul>
<!-- Plain getters and methods: DOTTED on the instance, no .value -->
<button @click="box.grow()">grow — area {{ box.area }}</button>
</template>The class carries the WHOLE contract; the namespace is identity and types
A class FILE is a SINGLE-FILE MODEL — the model-side twin of the single-file component. It has exactly three residents: imports, the class, the namespace. The component contract — prop types, prop defaults, their fusion, emits, and every tuning constant — lives ON THE CLASS as static getters, beside the state and behavior it governs. The namespace holds identity and TYPES only, every type DERIVED from $Class. Two worlds would make a class half extensible: a const in a namespace cannot be overridden by a subclass, is not inherited, and does not swap with Class under a global override — so a runtime declaration never lives there.
- Contract (on the class, static) —
static get propsTypes()(defineComponent-style, no defaults, returned throughdefinePropTypes({...})so therequired: trueliteral survivestypeof);static get propsDefaults()(plain values, annotatedExtractPropDefaultTypes<typeof $X.propsTypes>— required props are filtered out of the check automatically, and a deliberately default-free optional prop is declaredkey: undefined, stating the ruling in data);static get props()— the ONE fusion line,propsWithDefaults(this.propsDefaults, this.propsTypes), reading through the receiver so a subclass'spropsfuses ITS types and defaults;static get emits()(object-declared validators). The four contract members are ALWAYS getters — one form across every class, because a contract is what gets merged across classes (a base, a peer, a mixin) and a getter reads at first use, after every module has loaded; and it is read once per construction, so the getter costs nothing. Tuning constants and tables arestatic readonlyfields (see "Static data is a field"); the$prefix stays reserved for compute-once caches. Types and defaults stay two members ON PURPOSE: a variant re-tunes defaults without re-typing. A nested object prop (a knobs tree) is filled from the defaults at every depth withnestedProps(props, this.self.propsDefaults)fromivue/extras, once, in the constructor (in place — lodash'sdefaultsDeepwith arrays taken whole); the class reads complete props and never merges in a getter. Vue itself never merges a supplied object with its default. - Identity (namespace) —
$Class(raw, for children to extend),Class(Reactive(), for you tonew),Instance(andModelwhen used). - Types (namespace) — DERIVED from the class, never hand-duplicated:
PropsisExtractPropTypes<typeof $Class.props>(a generic component grafts its parameter back over the one prop a runtime map cannot carry:Omit<ExtractPropTypes<typeof $Class.props>, 'modelValue'> & { modelValue: T[] });EmitsisExtractEmitTypes<typeof $Class.emits>;Slots;ExposedisShallowUnwrapRef<Instance>. Domain types the contract refers to (an item shape, a variant preset) live here as namespace types; the class reads them asX.Item.
Combined — the canonical file, everything above in one shape:
// Box.ts — the whole module: imports, the class, the namespace. Nothing else.
import type { ExtractPropTypes, PropType, ShallowUnwrapRef } from 'vue';
import {
definePropTypes,
propsWithDefaults,
Reactive,
type ExtractEmitTypes,
type ExtractPropDefaultTypes,
} from 'ivue';
import { Static } from 'ivue/extras';
class $Box {
/* Contract — STATIC: owned by the class, extended with `super` */
/** 1 — the TYPES: a defineComponent-style object, no defaults inside.
* definePropTypes is an identity call that keeps `required: true` a
* LITERAL — a bare object widens it to boolean, which would blind the
* defaults check below. */
static get propsTypes() {
return definePropTypes({
title: { type: String as PropType<string>, required: true },
size: { type: Number as PropType<number> },
maxHeight: { type: Number as PropType<number> },
disabled: { type: Boolean as PropType<boolean> },
});
}
/** 2 — the DEFAULTS: plain values, typed against the types object.
* Required props (`title`) are filtered out of the check
* automatically; every OPTIONAL prop must appear — `undefined` is the
* explicit "no default ON PURPOSE" ruling, stated in data. A getter,
* like every contract member: it may read a knob through the receiver
* (a subclass re-tuning DEFAULT_SIZE re-tunes the default) or merge a
* peer's defaults, and it is read once per construction. */
static get propsDefaults(): ExtractPropDefaultTypes<typeof $Box.propsTypes> {
return {
size: this.DEFAULT_SIZE,
maxHeight: undefined, // unset = unbounded — deliberately default-free
disabled: false,
};
}
/** 3 — the FUSION: a standard Vue props object, ready for defineProps.
* Reads through the receiver — a subclass's `props` fuses ITS own
* types and defaults. Written once per hierarchy; a subclass that ADDS
* props re-declares this one line so its derived `Props` widens (a
* static's return type is not polymorphic). */
static get props() {
return propsWithDefaults(this.propsDefaults, this.propsTypes);
}
static get emits() {
return {
close: (title: string) => true,
};
}
/** A tuning constant: static DATA is a field, one value per class, read
* for free; a subclass overrides it. Annotated with its widened type
* because `readonly` narrows a literal to itself, and a subclass's
* `300` must still be a `number` to the base's `self`. */
static readonly DEFAULT_SIZE: number = 400;
/** The one cast per class: instance code reads its own statics here. */
protected get self() {
return this.constructor as typeof $Box;
}
// Type positions resolve non-positionally — the class names its own
// namespace's derived types freely.
constructor(
public props: Box.Props,
public emit: Box.Emits,
) {}
get title() {
return this.props.title;
}
get sizeLabel() {
return `${this.props.size}px`;
}
close() {
this.emit('close', this.props.title);
}
}
export namespace Box {
/* Identity */
export const $Class = Static($Box); // anchor — it declares statics; children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
/* Types — DERIVED from the class's statics, never hand-duplicated */
export type Props = ExtractPropTypes<typeof $Class.props>;
export type Emits = ExtractEmitTypes<typeof $Class.emits>;
export interface Slots {
default: (scope: { title: string }) => any;
}
/** What consumers hold through a template ref (expose unwraps refs). */
export type Exposed = ShallowUnwrapRef<Instance>;
}The SFC is pure wiring against the seam, and it reads the contract through Class — the mutable slot — so a global override swaps the contract together with the runner. The macros receive RUNTIME objects, so no compiler macro ever resolves a cross-file type:
const props = defineProps(Box.Class.props); // non-generic: the type is inferred
const emit = defineEmits(Box.Class.emits) as Box.Emits;
defineSlots<Box.Slots>();
// generic components cast the one graft:
// defineProps(X.Class.props) as unknown as X.Props<T>A subclass extends its contract the way it extends behavior — with super, overriding only what defines the specialization, with the reason on the line. Re-tuning a default needs ONE override; adding a prop needs the types override plus the one-line props re-declaration:
class $CardBox extends Box.$Class {
static override get propsDefaults(): typeof Box.$Class.propsDefaults {
return {
...super.propsDefaults,
size: 300, // cards are hundreds of px wide; rows were tens tall
};
}
}
class $TaggedBox extends Box.$Class {
static override get propsTypes() {
return definePropTypes({
...super.propsTypes,
tag: { type: String as PropType<string> },
});
}
static override get propsDefaults(): ExtractPropDefaultTypes<typeof $TaggedBox.propsTypes> {
return { ...super.propsDefaults, tag: '' };
}
static override get props() {
return propsWithDefaults(this.propsDefaults, this.propsTypes); // widens TaggedBox.Props
}
}The anchor rule is unchanged and now reaches every component class: a class that DECLARES statics — and the contract is statics — anchors at $Class with Static() (export const $Class = Static($Box)), and so does a subclass that overrides one. A subclass that only inherits stays raw. The anchor costs nothing on getters (native reads) and is what gives a $-cached static its compute-once semantics.
One seam, any size. A contract of forty documented props is still authored on its class — a static table scrolls like any other member, and a sibling XProps.ts would be the parallel world again (a second runtime owner the class mechanics cannot reach). Shared base surfaces are a base CLASS (class $ChooseField extends Field.$Class), never a spread-in const: inheritance is the only composition the contract uses.
Static data is a field; a static that computes, merges, or is the contract is a getter
A static that HOLDS data — a number, a string, a table, a shader, a list of seeds — is a static readonly field. A static that COMPUTES from other statics through the receiver — the props fusion, a default that reads a knob, a $-cached engine — is a getter. And the four contract members (propsTypes, propsDefaults, props, emits) are getters ALWAYS, whatever they hold: one form across every class, because a contract is what gets merged across classes and it is read once per construction, where a getter costs nothing and a second form would be variance for no gain. The line is what happens on read: a field is one object per class, read for free; a getter runs its body every time, and a getter that returns a literal table allocates that table on EVERY read. In a formatter called once per sample of every track, a static getter building { fromX, toX, … } afresh was a measurable share of each frame's cost; as a field it is one object for the life of the class.
// ✅ data: one value per class, overridable, free to read
static readonly COUNT: number = 15;
static readonly STARTLE = { burst: 0.42, riseMs: 140, settleMs: 900 };
static readonly RIDGES: Scene.Ridge[] = [
{ key: 'far', factor: 0.1 }, { key: 'near', factor: 0.5 }
];
// ✅ computation: reads the receiver, so a subclass's override applies
static get props() { return propsWithDefaults(this.propsDefaults, this.propsTypes); }
// ❌ data as a getter: a new table on every read, for no override a field would not give
static get STARTLE() { return { burst: 0.42, riseMs: 140, settleMs: 900 }; }Extension is unchanged: a subclass overrides the field, and extends a table by spreading super — legal in a static initializer, evaluated once, at class definition:
class $HawkFlock extends Flock.$Class {
static override readonly COUNT: number = 7;
static override readonly STARTLE = { ...super.STARTLE, flapBoost: 2.2 };
}The boundary, and it is the reason the getter form existed: a field's initializer runs ONCE, at class definition, in module evaluation order; a getter runs at first read, after every module has loaded. So a field may read super (the base is defined before the subclass by construction), its own class's earlier statics, and anything imported from a module OUTSIDE an import cycle. A static that reads another class which may import this one back — a table composed from a sibling's constant, a defaults map merged from a peer, a store, an engine — stays a getter, because at definition time that class can still be undefined. That is why every $-cached store and engine is a getter and stays one, and the gate's cross_module_class_reads_happen_inside_bodies check is the enforcement. A $ getter is the form for a value whose IDENTITY must be stable — a store, an engine, a memo table, a composed kit — computed once per receiver at first read; it is not the form for the contract, whose defaults must be fresh per read (an object default shared across instances is the bug Vue's factory rule exists to stop). The same for a static that probes the ENVIRONMENT — typeof CSS !== 'undefined' && 'highlights' in CSS, a window measurement, a feature flag: a field freezes the answer at module load, before a test installs the API or on a server that has none; a getter answers at the read. When in doubt about a cycle or an environment, the getter is never wrong; the field is faster only where it is safe.
One consequence of readonly to carry: it narrows a literal to itself (= 15 is the type 15), so a knob a subclass re-tunes carries its widened type (: number) or the subclass's value fails against the base's self. The forms interoperate across a hierarchy — statics are own properties of each constructor, so lookup is a chain walk, the form is per class, and super reads across the seam either way; instance members are the asymmetric case, where TypeScript refuses an accessor over a property. But the two forms make different PROMISES to a reader, and an override keeps the base's promise, whatever its form:
- a field promises ONE object — a reader may compare it by identity or keep a memo in it. Over a base field, an override is a field (
{ ...super.TABLE, mine: 1 }) or a$getter, stable per receiver; never a plain getter, which would hand the base's readers a fresh object on every read. - a plain getter promises a FRESH value per read — a reader may mutate its result. Over a base getter, an override is a getter; a field only when the value is immutable, or one object is shared across every read (the defaults hazard).
- a
$getter over a field is the one computing override a field admits: computed at first read, the identity a field promised kept.
The type checker cannot see this — both forms type the same — so the promise is the author's to keep; a gate check that reads the base's form across the import graph is an open item. The Static() anchor rule is unchanged: a class that declares statics anchors, fields included.
Instance-owned bookkeeping that is not state
An instance holds things that are neither reactive state nor derivation: a memo table, a queue of animations in flight, which chapter each slot currently draws, a batch's scratch. They are owned by the instance, mutated by its methods, and nothing renders from them. Their form is a readonly field holding a container that is mutated in place — a Map, an array, a plain bag — never a reassignable field (writes to a plain field trigger nothing, and the gate says so), and never a ref (a ref nobody watches is ceremony that also costs a dependency track on every read in a hot loop):
// ✅ instance-owned, mutated in place, rendered by nothing
protected readonly memo = { local: new Map<number, Local>(), span: new Map<number, Span>() };
protected readonly pieces: Piece[] = [];
protected readonly slotChapters = [0, 0];
// ❌ reassigned: the gate flags it, and a reader cannot tell it from state
protected trackCache: Track[] | null = null;When the thing must be REPLACED rather than mutated (a table rebuilt for a new element), it is a shallowRef getter like any state — the one place a ref is right, because the replacement is an event the class may need to observe.
A getter derives from state, never from the DOM
A plain getter is free because it is arithmetic over refs the engine tracks. A getter that QUERIES — querySelectorAll, getBoundingClientRect, a measurement — is a cost dressed as a derivation, paid on every read, and reads are what a getter invites. The track table of a scene was rebuilt from element queries on every frame of the callback path until it was cached per stage element. The form: a method that builds the table once (buildTracks(stage)), a shallowRef that holds it keyed by the element it was built for, and a getter that returns the held table. And a count, a label, a length that describes such a table derives FROM the table (this.trackList().length), never from a formula beside it — a subclass that adds a row cannot get the formula right.
Hot loops: hoist, memoise per batch, write once
A formatter that runs once per sample of every track is a hot loop, and the standard's free reads stop being free there. Three moves, in order of yield, all measured on a scene composing 37 tracks:
- Memoise geometry per batch. Every track's formatter derives the same chapter, span and progress from the same value; derive it once per value per batch (a
Mapon the instance, cleared when the batch begins) — 8,880 scroller lookups a piece became 240. - Write a constant once. A track whose formatted value does not change over a batch is one inline write, not an animation; the test is exact (every formatted value equal to the first), and the browser parses no keyframes for it.
- Hoist what the loop reads. A static read through
selfper iteration, a table a getter builds per read, aMathlookup — take them out of the loop into aconstabove it (or make the static a field, which is the same hoist done once per class).
Prefer the structural fix to the micro one: sampling the tracks every 250 ms and interpolating cut the keyframes from 35,000 to 2,000 in the same nine seconds, and no amount of hoisting inside the old loop would have found that.
Overrides say so out loud. noImplicitOverride is on: every member that overrides a base member carries the override keyword (protected override get offsetSize() { ... }). A silent override refuses to compile, and a base rename breaks every subclass at the exact overriding member instead of quietly orphaning it.
private is banned — visibility is a three-tier semantic. ivue's core promise is extend-don't-fork, and private is the one keyword that structurally revokes it: a subclass that needs a private member has exactly one option, copy the file. TypeScript's private is compile-time advisory anyway — it protects nothing at runtime and forbids only the legitimate extender. So every member picks its tier by AUDIENCE:
| tier | audience | meaning |
|---|---|---|
public | templates & consumers | the component/module surface |
protected | subclasses | a seam of the hierarchy — reachable to extend, invisible to templates and consumers (TS enforces this) |
private | nobody | banned — "must hide it even from subclasses" is a design smell; resolve by naming and documenting the member |
The pairing with noImplicitOverride is what makes protected-everything safe rather than fragile: every subclass touchpoint is annotated override, so a base renaming or removing a protected seam breaks every extender's BUILD at the exact member — seam drift is loud, never silent. (Both halves are load-bearing: protected opens every seam, the tsconfig makes changing one detectable.)
One template, one logic owner
Every behavioral SFC has exactly one ivue class as its template logic owner. <script setup> is the wiring boundary only:
- import dependencies;
- call compiler macros (
defineProps,defineEmits,defineExpose); - construct
new X.Class(...)once; - destructure the Ref/Computed bindings the template consumes.
Do not place component-local ref, computed, watch, lifecycle hooks, or free functions beside that instance. State belongs in ref-getters, derivations belong in plain getters, setup work belongs in the constructor, and event handlers belong in methods — even when the handler only normalizes a DOM event before delegating to a domain model.
One DOM event, one handler, named for the event. A template never binds two events to the same method (@pointerup="x.onUp" @pointercancel="x.onUp"), and a class never registers one method for two event types. A cancel gets onPointerCancel, whose body may be one line delegating to onPointerUp; a track's touchstart and touchmove get onTrackTouchStart and onTrackTouchMove even when both only claim the touch. The reason is the override seam: a subclass that must treat a cancel differently can override onPointerCancel alone, where a shared handler would make it re-derive which event it is handling from the event object — and the standard's whole point is that behavior extends by name.
When building on a class-backed component, extend its class, not its <script setup>. Add behavior to the existing class when it belongs to the same component contract. When it is a real specialization, subclass the raw class and publish the normal namespace:
class $SearchBox extends Box.$Class {
clearSearch() {
this.search.value = '';
}
}
export namespace SearchBox {
export const $Class = $SearchBox;
export let Class = Reactive($Class);
export type Instance = typeof Class.Instance;
}Never create a parallel behavior layer of setup functions around an existing class. That splits ownership, hides behavior from inheritance, and makes the template depend on two architectures.
A genuinely markup-only leaf may remain classless; do not manufacture an empty class for static presentation. The moment the component owns state, derivation, setup behavior, or an event handler, it has crossed the boundary and needs one class.
The template's two access styles carry meaning: a state binding = a destructured Ref/Computed, dotted box.x = a derivation or an action (plain getter / method) — the class's own anatomy, visible at the call site. Rules that keep it clean:
The destructure is TOTAL: every Ref/Computed the template touches is destructured; a Ref is NEVER reached through the instance in the template (interpolating
box.someRefrenders via display-unwrap, butv-if="box.someRef"is always-truthy — the seam the total destructure abolishes).In the
<script setup>BODY, destructured bindings are refs — use.valuethere as everywhere else. Inside<template>only, the compiler unwraps them.The remaining
.valueboundary: top-level component state is destructured and auto-unwrapped. Collection items and slot props are nested values, so Vue does not auto-unwrap their Ref fields; useitem.title.value. This is ivue's principal syntax tradeoff, preserving direct, allocation-free reads where lists are hottest.Perf escape (measured): a METHOD called in a render-hot path (per row of a large v-for) may be destructured — methods are identity-stable and the hoisted call runs at closure speed (~1.4 vs ~4 ns dotted). Reserve it for profiled hot paths; everywhere else methods stay dotted (the naming signal).
Instance-swapping components keep dotted access: if the component replaces its instance (
model.value = new X.Class()), destructured bindings would go stale — don't destructure what you swap.Don't shadow props. A destructured state binding with the same name as a
definePropsprop silently shadows it in the template (setup bindings win). Rare by construction: the class consumes props through prop-getters, so prop-derived values stay DOTTED (box.width,box.widthPx) and never compete with state-binding names.No logic in template expressions — name it as a derived getter.
v-if="items.length && !loading && mode === 'edit'"is an anti-pattern: the condition has no name, duplicates across call sites, and its pieces can't be tested. Every combination, comparison or ternary lives on the class as a PLAIN getter whose name says what the condition MEANS —v-if="box.canEditItems". When the condition takes an argument (per-item in av-for), the same rule wears its method form —v-if="media.fileExists(index)"— still a name, still no inline logic. In ordinary Vue this discipline costs acomputed()per condition, so nobody keeps it; here a named plain getter costs zero bytes, so there is no excuse. Templates read as prose: bindings, names, and events — never expressions. The split is stage directions and script: the template says who is on stage and what happens when someone acts; the class says what everything MEANS. Structure stays in the template, meaning moves to the class.The rule covers EVERY binding kind, not just
v-if— the common leaks are display strings, disabled states, and class objects:leaked into the template derived on the class interpolating sending ? 'Sending…' : 'Send to ' + recipients.lengthinterpolating model.sendButtonLabel:disabled="!model.canSend || sending":disabled="model.sendDisabled":class="{ active: view === tab.name }":class="{ active: app.isOpen(tab.name) }"row.name || '—'in av-forcellFormat.Class.orDash(row.name):stylewidth from(day.count / peak) * 100 + '%':stylewidth frommodel.barWidth(day)Each right-hand form is a prototype member: unit-testable without mounting anything, greppable by name, typed, and hot-graftable. The one thing that stays in the template is STRUCTURE —
v-if/v-elsebranching on a named condition or a data field (v-if="entry.nextSlug") andv-forover a collection. Branching on data is structure; COMPUTING with data is logic, and logic lives on the class.
The outliving instance (module singleton, entity)
For an instance that OUTLIVES any component — a module singleton, an entity created in a callback — watchers go in the instance's OWN scope, and the owner of its lifetime disposes it:
import { Reactive, type ReactiveHelpers } from 'ivue';
class $Session {
get user() {
return ref<User | null>(null);
}
// Outliving instance: $watch/$watchEffect register in the
// instance's lazy effectScope — there is no component scope here
// to reap plain watch.
// WATCHERS live behind a method, not inline in the constructor —
// the constructor calls it once, and the instance can RESTART its
// watchers after a keep-state stop (see suspend() below).
constructor() {
this.startWatchers();
// If constructed INSIDE some scope, auto-wire teardown instead:
// getCurrentScope() && onScopeDispose(() => this.$stopEffects());
}
startWatchers() {
this.$watch(
() => this.user.value,
(user, previousUser) => this.onUserChanged(user, previousUser),
);
this.$watchEffect(() => this.persist());
}
// SUSPEND / RESUME: { reset: false } stops the watchers ONLY — every
// cached cell survives with its current value. startWatchers() in a
// fresh scope resumes. (Default $stopEffects() also CLEARS the cells:
// the next touch re-runs initializers — disposal is a reset.)
suspend() {
this.$stopEffects({ reset: false });
}
resume() {
this.startWatchers();
}
// CLEANUP composes as an ORDINARY method — no hooks, no reserved
// names, ivue never auto-calls your code. Do the non-Vue work
// (sockets, listeners from composables), then reset the engine.
dispose() {
this.disconnect();
this.$stopEffects();
}
onUserChanged(user: User | null, previousUser: User | null) {
/* ... */
}
persist() {
/* ... */
}
disconnect() {
/* ... */
}
}
export namespace Session {
export const $Class = $Session; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
// the type of every unwrapping surface (defineExpose, reactive())
export type Instance = typeof Class.Instance;
}
// The engine installs $watch / $watchEffect / $stopEffects at Reactive(),
// AFTER the class body was typed — so a class that calls them merges the
// helpers into its own instance type. One line, zero runtime; the gate
// reads it as the class's second half, never as a stray type.
interface $Session extends ReactiveHelpers {}
// The owner disposes — the class's own method, like any other:
session.dispose();DO / NEVER
| DO | NEVER |
|---|---|
class $X + export namespace X { $Class; Class = Reactive($Class); Instance } | export a bare Reactive(class {...}) for anything that grows a parent/dependent |
mutable state = get x() { return ref(v) } | put mutable state in a plain field — writes trigger nothing |
.value for every Ref/Computed inside the class and in the script body | write this.x = v for a Ref/Computed in the class — it clobbers the ref or no-ops |
| derive with a PLAIN getter | wrap every derivation in computed() — pays ~300 bytes/instance for nothing |
computed() only for expensive / render-suppressing / stable-handle needs | reach for computed() by default |
inject stores via protected get $store() { return useStore() } | store = useStore() field initializer — runs at construction, breaks tests/SSR/cycles |
new X.Class(props, emit) — raw instance everywhere | wrap in reactive(instance) or any shallow-unwrap view as the standard |
| destructure ALL template-touched Refs/Computeds + element refs, grouped | destructure plain getters or methods — snapshots a dead value / loses nothing but clarity |
state bindings in templates; dotted box.x only for plain getters/methods | reach a Ref through the instance in a template — v-if="box.someRef" is always-truthy |
labels, disabled states, and class conditions as named getters/methods (model.sendButtonLabel, model.sendDisabled) | ternaries, ||/&& chains, comparisons, or string-building inside template expressions |
defineExpose(box as X.Instance) | defineExpose(box) raw — readonly-accessor writes will type-error for consumers |
| constructor runs init; register hooks/watchers there | add an init() method expecting auto-call — ivue never calls it |
plain watch in component-scoped constructors; $watch + a $stopEffects dispose path for outliving instances | default to this.$watch in a component-scoped class — its scope silently outlives unmount |
a class that calls this.$watch / $watchEffect / $stopEffects merges the engine's helpers beside itself: interface $X extends ReactiveHelpers {} (one line, zero runtime) | (this as any).$watch(...) or per-member declare $watch: … lines — the body should typecheck without a cast |
compose cleanup as an ordinary method — dispose() { /* non-Vue cleanup */ this.$stopEffects(); } | expect a teardown hook — ivue auto-calls NOTHING (no init(), no stopEffects()) |
a class with static members anchors them: const $Class = Static($X) (ivue/extras) | extends X.Class — the mutable slot is an eager snapshot of one generation; always extend $Class |
protected for every internal member — subclasses reach every seam | private anywhere in an ivue class — it forbids only the legitimate extender |
instance code reads its own statics through this.self (the one cast per class); hoist const self = this.self for 2+ reads or any loop | per-site (this.constructor as typeof $X) casts — each one is an unchecked class-name assertion |
The unwrapping-surface typing invariant
Vue's expose proxy and reactive() unwrap ref READS and redirect ref WRITES into .value at runtime — but TypeScript keeps get-only accessors readonly through its homomorphic unwrap types. So a surface typed from the raw class FORBIDS writes the runtime allows. Instance (= ReactiveInstance, i.e. typeof Class.Instance) strips readonly via its writable-getter remap. It is the TYPE of every unwrapping surface.
- Producing an exposed instance:
defineExpose(box as X.Instance). - Consuming a template ref to it:
ShallowUnwrapRef<X.Instance>(generic:ShallowUnwrapRef<X.Instance<T>>). - Wrapping at an interop boundary:
reactive(instance as X.Instance)(concession, not the standard).
Across expose, verified live: reads arrive unwrapped; ref-writes DO redirect (there is a write path); methods arrive engine-bound to raw; and PLAIN GETTERS STAY FULLY REACTIVE — watch(() => ref.value.someDerived, cb) fires on leaf change. What does NOT survive: setup-time snapshots (const v = ref.value.x), plain data fields (never reactive), pre-mount null (template refs are null until mount — use ?. in watch getters).
Common compile errors → fixes
| Error / symptom | Fix |
|---|---|
Cannot assign to 'x' because it is a read-only property (on an exposed/reactive()/template-ref surface) | type that surface through X.Instance |
Type 'boolean' is not assignable to type 'Ref<boolean>' | missing .value on a Ref/Computed write — x.flag.value = true |
'X' is possibly null on a template ref in a watch getter | add ?. — watch(() => x.boxEl.value?.foo, cb) |
| template write crashes / no-ops at runtime on the raw instance | you wrote x.Ref/Computed = v; write x.Ref/Computed.value = v |
Watch rules — and WHICH watch
| the instance is… | use |
|---|---|
component-scoped (created in setup()) | plain watch / watchEffect — the component scope stops them on unmount |
| component-outliving (module singleton, created in a callback) | this.$watch / this.$watchEffect — the instance's lazy scope; disposed by $stopEffects() |
watch(() => instance.plainGetter, cb)works on a RAW instance — noreactive()wrapper, no Ref/Computed needed. The getter body runs inside the watcher's effect, so its leaf reads subscribe directly (non-intuitive but structural).- The source MUST be the FUNCTION form.
watch(instance.plainGetter, cb)passes a dead snapshot and never fires. $stopEffects()stops the instance scope and clears cached Refs/Computeds (the next touch re-materializes — disposal is a reset);$stopEffects({ reset: false })stops the WATCHERS only — every cached cell survives with its current value, andstartWatchers()in a fresh scope resumes (the suspend/resume pattern above); instances that never$watchallocate no scope. There are NO hooks — richer cleanup is an ordinary method that does its work and then calls$stopEffects()itself. Every outliving instance needs an OWNER that calls it — or, when constructed inside some scope, auto-wire:getCurrentScope() && onScopeDispose(() => this.$stopEffects());- Do NOT default to
this.$watchin a component-scoped constructor: the component scope cannot see the instance scope, so without$stopEffectswiring that watcher outlives unmount. - Lifecycle hooks (
onMounted,onUnmounted, …) follow the same split: the constructor runs synchronously where younew, so in a component-scoped class they register against the mounting component — full setup toolbox. Component-coupled classes ONLY; never in stores/entities that outlive components. If the class is also constructed outside components, guard:getCurrentInstance() && onMounted(() => this.onMount()); - Watch CALLBACKS delegate to methods (the thin-closure rule):
watch(source, (newValue, oldValue) => this.onChanged(newValue, oldValue)).
computed() and watch callbacks delegate to methods
A reactive closure is cached per instance. Keep that closure as a small pointer to behavior on the prototype: closures connect; methods contain logic.
// ✅ THIN — the closure only delegates; logic stays named and testable
get sortedItems() {
return computed(() => this.sortItems());
}
sortItems() {
return [...this.items.value].sort(byPrice);
}
// ✅ same rule for watch callbacks wired in constructors
watch(value, (newValue, oldValue) =>
this.onValueChanged(newValue, oldValue),
);
// ❌ FAT — logic is anonymous and duplicated inside the cached closure
get sortedItems() {
return computed(() => [...this.items.value].sort(byPrice));
}Also buys: guaranteed-minimum memory (the thin closure captures nothing but the instance — a fat closure silently pins any getter-scope local for the instance's lifetime) and direct testability (instance.sortItems()). Reactivity is unaffected — reads inside the method are tracked through the computed's evaluation exactly as if inlined.
Do NOT "optimize" the arrow away to computed(this.sortItems): it works (ivue methods are lazy-bound) but Vue 3.4+ passes the previous value as the getter's first argument, so a method that later gains an optional parameter silently receives stale data. Always the arrow.
$-prefixed singleton getters are frozen caches too — keep their bodies to a single composable/service call (return useThing()), nothing more.
The store pattern: a singleton behind use(), injected by $-getter
Shared application state (session, navigation, toasts, the current user) is a STORE — one ivue class published as a module singleton — never a model passed down as a prop. Prop-drilling a shared model (<ChildView :app="app" />, constructor(public app: AppModel.Instance)) threads one object through every component and constructor signature it crosses; the store pattern deletes the thread.
// app/AppStore.ts — the store IS an ivue class; a static owns the singleton
// (imports: Reactive from 'ivue'; Static from 'ivue/extras')
class $AppStore {
// The ONE instance, as a `$`-static: constructed on first read, after
// the app exists, and cached on the receiver. It constructs through the
// namespace slot, so a test double swapped into `Class` is what gets
// built — the store has one receiver, the slot, so nothing forks.
protected static get $shared(): AppStore.Instance {
return new AppStore.Class();
}
static use(): AppStore.Instance {
return this.$shared;
}
get authenticated() {
return ref(false);
}
notify(message: string) {
/* ... */
}
}
export namespace AppStore {
export const $Class = Static($AppStore); // anchor — it declares statics
export let Class = Reactive($Class); // reactive — use() does the one `new`
export type Instance = typeof Class.Instance;
}Consumers never receive it — they REACH for it:
// any model — the `$`-getter caches the store per instance, forever
class $SubscribersModel {
protected get $app() {
return AppStore.Class.use();
}
async refresh() {
try {
/* ... */
} catch (error) {
this.$app.reportFailure(error);
}
}
}<script setup lang="ts">
// any component — call use() directly; no prop, no provide/inject
import { AppStore } from '../app/AppStore';
const app = AppStore.Class.use();
const { authenticated } = app;
</script>
<template>
<button v-if="authenticated" @click="app.logout()">Lock</button>
</template>Why this shape and not alternatives:
use()is lazy — the singleton constructs on first touch, after the app exists, so module-load order and circular imports stay non-events (the same late-read property as every cross-module reference). It lives in a$-static on the class — never a namespacelet, which is a parallel world no subclass can reach (the gate'sthe_namespace_holds_identity_and_types_onlycheck refuses it). A$-static caches per receiver, and a store reached only throughX.Class.use()has one receiver, so nothing forks;LazySharedis for a REGISTRY that several receivers (subclasses) must share.- The
$-getter is the injection point — cached whole, per instance, on first read. A model names its dependency once; every method readsthis.$appwith zero lookup cost and zero constructor plumbing. - A store may publish itself as a
reactive()view —use()stays the one door; the$-static behind it returnsreactive(new AppStore.Class() as AppStore.Instance)and consumers read and write with no.value. Theas Instancecast is the interop form the gate sanctions (it is what makes the unwrapped writes typecheck); barereactive(new …)is refused. - Tests swap the slot, not the callers —
AppStore.Class = $TestStorebefore the firstuse()and every consumer, callingAppStore.Class.use(), gets the double through the same seam. - A store is component-OUTLIVING by definition: watchers inside it use
this.$watch/$watchEffect, never plainwatch, and lifecycle hooks never belong in it. - Pass PROPS for what is genuinely per-instance input (a row, a slug, a config knob). Reach for the STORE for what is genuinely shared. A prop named
app,store, orsessionis the tell that a store is being drilled.
Keyed reactivity — the third state shape
Ref-getters express NAMED members; shallowRef expresses wholesale-replaced structures. When state is KEYED — sparse, unbounded, indexed by ids or coordinates unknown until runtime (cells by (row,col), entities by id, rows of a stream) — a getter per key is impossible. Hold collections of reactive primitives as plain values and materialize per observation:
class $Sheet {
// Plain readonly fields — the COLLECTIONS aren't reactive;
// their VALUES are.
protected readonly cellVersions = new Map<number, Ref<number>>();
/**
* READ path: get-OR-CREATE, then subscribe — observation
* materializes.
*/
protected trackCell(cellKey: number): void {
let versionRef = this.cellVersions.get(cellKey);
if (!versionRef) {
versionRef = ref(0);
this.cellVersions.set(cellKey, versionRef);
}
// subscribes whatever effect is currently running
void versionRef.value;
}
/**
* WRITE path: PEEK-ONLY — unobserved keys allocate nothing,
* notify no one.
*/
protected bumpCell(cellKey: number): void {
const versionRef = this.cellVersions.get(cellKey);
if (versionRef) versionRef.value++;
}
}The read/write ASYMMETRY is the pattern: reads get-or-create (cost is priced by observation), while writes to unobserved keys allocate no signal. Rules that keep it honest:
- Ground truth lives in plain storage (typed arrays, Maps); the refs are VERSION SIGNALS, not value holders — bump to invalidate, readers re-derive.
- Per-key cached computeds follow the same shape (
Map<key, ComputedRef>), bodies delegating to methods (the thin-closure rule), and MUST have an explicit release/ eviction path — keyed overlays cannot GC on their own (the Map holds strong refs; attached watchers subscribe permanently). - Coarse tiers are the same pattern at lower resolution: one ref covering many keys (a block of rows, a whole-collection version counter) for subscribers that span many keys — one integer where naive design puts a million nodes.
- No wrapper needed:
ref()/computed()are first-class values from@vue/reactivity; Maps of them inside aReactive()class compose with everything (methods stay bound and$watchworks).
| state shape | expression |
|---|---|
| named members | get x() { return ref(v) } |
| wholesale-replaced structure | get rows() { return shallowRef<Row[]>([]) } |
| keyed / sparse / unbounded | Map<key, Ref> + get-or-create track, peek-only bump |
Same invariant at three granularities — nothing exists until observed: getters price MEMBERS, keyed collections price KEYS. (Proven at 20M cells / 4.7 bytes each — see the flyweight grid.)
Generic classes (brief)
ReactiveClass<C> cannot carry <T> through (no higher-kinded types), but Reactive(X) === X by identity — so cast Class back to the raw constructor and apply ReactiveInstance explicitly for Instance:
class $Scroller<T extends BaseItem> {
get items() {
return ref<T[]>([]);
}
}
export namespace Scroller {
export const $Class = $Scroller;
// the cast keeps <T> available at `new` sites
export let Class = Reactive($Class) as unknown as typeof $Class;
export type Instance<T extends BaseItem> =
ReactiveInstance<$Scroller<T>>;
}
// consumer of a template ref: ShallowUnwrapRef<Scroller.Instance<T>>Circular references resolve by construction
The hoisted-namespace + getter convention makes late cross-module references safe without ordering discipline or forwardRef-style workarounds:
- Cross-references (
new Other.Class()in a method, a store read in a$-getter) resolve at FIRST ACCESS, when every module in the cycle has long finished loading — any load order works. - Each file calls
Reactive()on its own class safely: it is idempotent per prototype level; a shared ancestor is transformed once, by whichever file loads first. - Eager top-level dereferences can still fail; the convention keeps cross-references inside late method and getter bodies. Circular
extendsstays impossible because it evaluates at load time and both parents cannot exist first.
Static() — the static-side sibling (from ivue/extras)
Reactive() owns instances. Stateless CAPABILITY classes — function bags for files, git, parsers, clocks: never constructed, only called and swapped — use Static() from the ivue/extras entry (separate, so core stays the engine):
Static methods bind lazily with stable identity — detachable, safe as a router/queue/listener callback, bound to the RECEIVING class.
Get-only statics named
$…compute once PER RECEIVER. The$prefix promises stable identity, NOT immutability — a mutable memo table is a legitimate$-cache. Non-$static getters stay LIVE: the settings a subclass or test double overrides.A SHARED STORE never lives in receiver-space. Per-receiver caching means a subclass reading
this.$storesilently forks a fresh copy — the registry-fork trap. The store is astatic readonlyFIELD on the declaring class — one reference, inherited through the prototype chain, never receiver-cached — so every receiver read (this.$store,this.constructor.$store) resolves to the one store with no special case anywhere; the$-getter pins by returning the field:tsclass $Registry { protected static readonly sharedRegistrations = new Map<object, Registration>(); protected static get $registrations() { return this.sharedRegistrations; // the field IS the pin } }Two questions place every static value:
- Should a subclass get its own copy? Yes → per-receiver
$-cache. That is what memos and per-class tuning want: forking on subclass is the feature. No → it is a SHARED store (a registry, a ledger — forking is the bug), and it lives in astatic readonlyfield as above. - Shared store: can its initializer run at module load? A field initializer runs while modules are still loading, so it may only hold a dependency-free value — a bare
new Map(), a literal. The moment construction needs ANOTHER module's class, the field holds aLazySharedcell instead (import { LazyShared } from 'ivue/extras'), and the$-getter reads through it:tsEach step is safe on its own terms. Storing the cell eagerly is safe because a thunk evaluates nothing at load. Running the thunk on first read is safe because by then every import cycle has resolved. And sharing is safe because the memoized value lives INSIDE the cell — every access path, subclass receivers and per-receiverprotected static readonly sharedBackend = new LazyShared( () => new SearchBackend.Class(), ); protected static get $backend() { return this.sharedBackend.value; }$-caches over the cell included, converges on the one constructed singleton.
- Should a subclass get its own copy? Yes → per-receiver
THE ANCHOR RULE — a class that declares static members wraps them ONCE, at $Class, so subclasses and test doubles inherit working semantics by extending $Class bare:
import { Static } from 'ivue/extras';
class $GitCommands {
static get binary() {
return 'git'; // LIVE knob — no $ prefix
}
static get $environment() {
return { LC_ALL: 'C' }; // computed once per receiver
}
static stage(path: string) {
return this.run(['add', '--', path]); // `this` = receiving class
}
}
export namespace GitCommands {
export const $Class = Static($GitCommands); // anchor — wrap HERE
export let Class = $Class; // selection — kernels/tests swap this
}Statics AND reactive instances on one class — anchor the statics, then Reactive():
export namespace Settings {
export const $Class = Static($Settings);
export let Class = Reactive($Class); // in-place: Class === $Class
export type Instance = typeof Class.Instance;
}No static members → no wrapper: $Class = $X, the standard form unchanged.
Hot loops read the method through the accessor — hoist it, not the class. The bound method itself is plain-function speed (measured, Chromium, 9M calls, fresh page per variant: module function 31.7 ms, hoisted bound method 30.0 ms); the ONLY per-call cost is re-reading it through the accessor inside the loop (84.6 ms same loop — the own-property guard that buys per-receiver binding). Ordinary call frequency never notices. In a million-call loop, destructure once, INSIDE the function:
// one accessor read per method — a late read of the mutable slot,
// so a swapped-in subclass is still honored
const { isDataCol, numDataValue } = FlyweightLogic.Class;
for (let row = 0; row < ROWS_1M; row++) sum += numDataValue(row, col) ?? 0;Never hoist at module scope (captures today's Class forever, blind to swaps) and never reach for $Class as a "fast path" — the raw class skips per-receiver binding, which is the capability seam itself.
Reading your own statics — the ladder
Reactive(X) === X, so a namespace's Class slot IS the base class. A getter that reads statics through it therefore hard-binds to the base and silently IGNORES a subclass override — the exact opposite of what a live (non-$) static getter is for:
// ❌ three members, a double cast, and the override never applies
protected get Tooltip() {
return Tooltip.Class as unknown as typeof $Tooltip;
}
public static get TOOLTIP_DWELL_SECONDS() { return 0.4; }
protected get tooltipDwellSeconds() {
return this.Tooltip.TOOLTIP_DWELL_SECONDS; // base value forever
}Measured: a subclass setting 0.1 still reads 0.4 through this shape.
Take the first rung that applies:
- Nothing outside the instance reads it → delete the static. A plain instance getter is zero bytes per instance and natively overridable:ts
protected get tooltipDwellSeconds() { return 0.4; } - Something outside reads it (a test overriding the knob, another class) → keep the static and read it through
self— the one cast per class, declared beside the statics it types — DIRECTLY at each call site:tsAn instance getter over a static earns its place when it genuinely derives — mixing in instance state or transforming the value; a plain read stays a directprotected get self() { return this.constructor as typeof $Tooltip; } show() { this.dwellTimer.start(this.self.TOOLTIP_DWELL_SECONDS); }this.self.Xat the call site, so the knob keeps one name and one override surface (the static).this.constructoris the actual class — the subclass when subclassed, and an engine class that INHERITS$Classfor a plain reactive instance — so statics resolve late-bound in both cases. TypeScript typesconstructoras bareFunction, so ONE cast is unavoidable;selfis where it lives. Never scatter per-site(this.constructor as typeof $X)casts: each is an unchecked assertion that the class name is right, and the copy-paste error it invites typechecks silently against the wrong statics. Rules that keepselfhonest:- Plain getter, never
$self— a$-cache would spend a per-instance slot on whatthis.constructorhands back for free. - One read →
this.self.Xinline. Two or more reads, or any loop → hoist:const self = this.self;as the first line, thenself.Xthroughout. Measured (Node 26): the de-optedselfgetter costs ~2 ns/read over an inline cast — noise for a single read — while the hoisted form runs at ~0.4 ns/iter in loops, CHEAPER than the inline cast, because the engine hoists the class as a loop constant. - A subclass that adds statics redeclares
selfwith its owntypeof $Sub(a covariant override); a subclass that only tunes inherited statics needs nothing —selfis already late-bound. selfis NOT the namespace slot.this.selfis the class you were constructed from;Namespace.Classis the live mutable slot a kernel may have re-pointed since. Receiver statics (constants, per-class tuning,$-caches) read throughself; late-bound capability dispatch reads throughNamespace.Class. Blurring them trades typo bugs for staleness bugs.
- Plain getter, never
- Overriding must NOT happen → name the class directly,
$Tooltip.TOOLTIP_DWELL_SECONDS, and let the code say so.
Never introduce a protected get <ClassName>() self-reference getter. It is a cast wearing a getter costume: it looks live and is not — self is its honest replacement.
Naming: unfold to the domain
Readable code is the product. In ivue classes the class shape already reads like prose — don't ruin it with letter soup:
- No single-letter or abbreviated identifiers — including loop indices and callback parameters.
row/col, notr/c;cell,cellValue,entry,versionRef,aggregate,newValue/oldValue, notc/v/e/agg/nv/ov. - The one-letter-many-meanings failure mode is the reason. A file where
cmeans cell in one method, column in the next, and cellValue in a third makes every reader re-derive the type system in their head. Named after the domain, the ambiguity cannot exist. - Booleans are predicates (
isFineTier,hasModel); counts say what they count (observerRuns,releasedCount); prior values areoriginalX/previousX, notold/prevalone. - Abbreviate only when the abbreviation IS the domain term (
px,id,fx, A1-notation likestartRow/endCol). - Tests are code — the same rules apply to specs.
- A
v-foralias is a declaration the template makes — the same rule:v-for="(cell, columnIndex) in sheet.grid[row]", never(cell, ci) in sheet.grid[r]. The template is read by the same people as the class;r,c,cicost them the same re-derivation there.
// ❌ const v = this.cellVersions.get(k);
// ✅ const versionRef = this.cellVersions.get(cellKey);
// ❌ for (let r = r1; r <= r2; r++)
// ✅ for (let row = startRow; row <= endRow; row++)
// ❌ watch(c, (nv, ov) => …)
// ✅ watch(value, (newValue, oldValue) => this.onChanged(…))Spacing is information
Contiguity says "same kind of thing"; a blank line says "the kind changes, or complexity rises." Spend the signal deliberately — a blanket newline-between-everything rule makes air mean nothing.
Class members use one order: static members → constructor → state getters → prop getters → derived getters → methods. The constructor is the first instance member. Comments and invariant annotations can precede the member they describe.
Constants use one form per role:
| Role | Form |
|---|---|
Tunable or overridable class constant — a literal (annotated with its widened type), or a table ({ mouse: Selection.Class.AUTOSCROLL_MOUSE }) | static readonly SCREAMING_SNAKE_CASE |
| A constant that computes from another static through the receiver, reads another class, or probes the environment | static get SCREAMING_SNAKE_CASE() |
The contract — propsTypes, propsDefaults, props, emits | static get, always |
| Contributor or pane identity data | Instance readonly lowerCamelCase field |
| Extensible constructed dependency | Field assigned from a prototype createX() factory method |
| Any other supposed constant | Defect: choose the real role or remove it |
Read live statics through the receiving class. JavaScript dispatches getter and prototype method overrides while a parent constructor runs. A subclass field initializer runs only after super() returns. It cannot change parent construction. This mechanism makes getters safe tunables and createX() methods safe construction seams.
// state block — CONTIGUOUS: reads as the instance's STATE TABLE
get sheet() {
return shallowRef<Sheet | null>(null);
}
get scrollTop() {
return ref(0);
}
get editing() {
return ref<{ row: number; col: number } | null>(null);
}
// derived block — contiguous: the windowing math as ONE visual unit
get totalHeight() {
return Math.min(this.naturalHeight, MAX_SCROLL_HEIGHT);
}
get startRow() {
return Math.floor(this.virtualTop / ROW_HEIGHT);
}
/** A doc comment needs air — blank line before it. */
get offsetY() {
const windowTop = this.virtualTop - this.startRow * ROW_HEIGHT;
return this.scrollTop.value - windowTop;
}- Declaration-like getters (state refs, one-expression deriveds): contiguous within their group — a
get x() { return ref(0) }is morally a field, and fields read as a struct-like table you absorb at a glance. The GROUP is the unit, not the member. - Blank line the moment a member carries a doc comment or multi-line logic — comments and paragraphs of code need air.
- Blank line +
// --- section ---banner between categories (state → derived → methods) — the boundary that actually matters. - Methods: always separated — they are paragraphs, not table rows.
Not machine-enforceable (linters can't tell a ref-getter from a method, and Prettier expands getters past the single-line exemptions) — hold it as a convention and check it in review.
Self-review checklist (run over your ivue diff)
- Every mutable state member is
get x() { return ref(...) }— no mutable plain fields. - Inside the class, every Ref/Computed read/write uses
.value; every plain field matches one role in the constants table. - Derived values are PLAIN getters;
computed()appears only for expensive / render-suppressing / stable-handle cases. - Stores/composables are injected via
protected get $store() { return useStore() }, not field initializers. - The class is exported through the namespace (
$Class/Class = Reactive($Class)/Instance); generics castClassand hand-applyReactiveInstancetoInstance<T>. - The SFC does
new X.Class(...)once — noreactive()wrapper, no unwrap view. -
<script setup>is wiring only: no component-local Ref/Computed, watcher, lifecycle hook, or free function beside the class instance; extend an existing class-backed component through its class, never through parallel setup behavior. - The SFC destructures ALL template-touched Refs/Computeds + element refs (grouped: state refs / computed refs / element refs); templates use state bindings and dotted access ONLY for plain getters/methods — no Ref reached through the instance in a template, no state name shadowing a prop.
- Template expressions carry NO logic — every
&&/||/comparison/ternary condition is a NAMED plain getter, or a NAMED method when it takes an argument (v-if="box.canEditItems",v-if="media.fileExists(index)"— neverv-if="a && b"). - Nothing but Refs/Computeds/element-ref targets is destructured (never plain getters/methods); v-for item cells stay dotted with
.value; instance-swapping components don't destructure at all. -
defineExpose(x as X.Instance); consumers type the ref asShallowUnwrapRef<X.Instance>. - Watch sources are the FUNCTION form; component-scoped constructors use plain
watch/watchEffect;this.$watch/this.$watchEffectonly for component-outliving instances — each with a dispose path ($stopEffects()owner oronScopeDisposeauto-wire). - Lifecycle hooks / init logic live in the constructor (no
init()expecting auto-call); template refs guarded with?.where read pre-mount. - Every
computed()/constructor-watch CALLBACK delegates to a method (computed(() => this.recalculate())) — no logic inlined in reactive closures; the arrow form, nevercomputed(this.method). - Identifiers are unfolded to domain words (
row/col/cell/cellValue/versionRef…), loop indices,v-foraliases and specs included — no single-letter names, no name meaning different things in different methods. - Keyed/sparse state uses the Map-of-refs shape (get-or-create on read, peek-only bump on write, explicit release path) — never one getter per key, never a deep
reactive()collection. - Static members are anchored (
const $Class = Static($X));$-prefixed static getters are compute-once-per-receiver caches, static DATA is astatic readonlyfield (a knob a subclass re-tunes annotated with its widened type, a table extended by spreadingsuper), a static that computes through the receiver is a getter, a hierarchy agrees per name, and inheritance extends$Class— never the mutableClass. - Instance-owned bookkeeping that nothing renders from (memos, queues, slot ordinals) is a
readonlycontainer mutated in place — never a reassignable field, never an unwatched ref; a table that must be REPLACED lives in ashallowRefgetter. - No plain getter queries or measures the DOM: a table built from elements is built by a method once per element and held; a count or label derives from the table it describes, never from a formula beside it.
- A hot loop (a formatter per sample per track, a per-frame write) memoises shared geometry per batch, writes a constant once instead of animating it, and hoists what it reads; the structural fix (fewer samples, interpolation) comes before the micro one.
- Million-call loops over a
Static()class destructure the bound methods once inside the function (never module-scope, never$Class);Class.method()stays the form everywhere else. - Instance reads of own statics go through
this.self(declared once per class needing it, cast totypeof $X, plain getter never$self); 2+ reads or loops hoistconst self = this.self; no per-sitethis.constructorcasts;Namespace.Classreads stay reserved for late-bound capability dispatch. - Static members precede the constructor; the constructor precedes state, prop, and derived getters; methods come last.
- Spacing carries meaning: declaration-like getters contiguous within their group; blank lines only where a doc comment / multi-line body / category boundary begins; methods always separated.
- A class that calls
this.$watch/$watchEffect/$stopEffectsmerges the engine's helpers beside itself —interface $X extends ReactiveHelpers {}— so the body typechecks (never(this as any), never per-memberdeclarelines). - The class carries the WHOLE contract as static getters (
propsTypes,propsDefaults, the one-linepropsfusion,emits— always getters, one form across every class) and its tuning knobs and tables asstatic readonlyfields and the namespace holds identity and types ONLY, every type derived from$Class; no module-level consts or TYPE declarations beside imports/class/namespace (every type a class file declares is a namespace member, read asX.Name), noconst,let, orfunctionof any kind in the namespace — contract data, tuning knobs, seed data, singletons (use()), helpers all live on the class as statics (the gate'sthe_namespace_holds_identity_and_types_onlycheck enforces it), no siblingXProps.ts; the SFC readsX.Class.props/X.Class.emits; a subclass extends the contract withsuperand re-declares the fusion line only when it ADDS props. - Every member that overrides a base member carries
override(withnoImplicitOverrideenabled). - No
privatemembers — internal members areprotected(three-tier visibility: public = consumer surface, protected = hierarchy seam, private = banned).