The reactive rewrite
Why count * 2 works when count is a signal, and what it costs.
The problem
A signal has to be an object — it needs identity, subscribers, and a place to put the current value. But authoring wants it to behave as its value:
count * 2 // NaN, if count is an object
count === 3 // false, for ever
`at ${count}` // "at [object Object]"
count ? a : b // always the first branch
The usual answer is .value everywhere, or a proxy with Symbol.toPrimitive. dziry
does neither.
The answer: rewrite the source
A Bun plugin rewrites your modules before they are transpiled. Every identifier read
becomes a call to $:
// You write
const doubled = computed(() => count * 2);
// It compiles to
const doubled = computed(() => $(count) * 2);
$ unwraps a signal and passes everything else through:
export function $<T>(value: T) {
if (isSignal(value)) return value.value;
return value;
}
Why this is decided at run time
That predicate is the whole design. The transform rewrites every identifier it sees without knowing which ones are signals — and it cannot know, because that would need type information and a resolved module graph.
Deciding at run time means the transform needs neither. No types, no scope analysis, no module resolution. It is a syntactic rewrite.
Which makes over-rewriting safe rather than merely tolerable:
todos.filter((t) => $(t).done);
$(t) returns t, because t is a plain parameter and that is the binding in scope.
A parameter shadowing a module-level signal resolves to the parameter, correctly. The
cost of a read that was never a signal is one predicate.
Member expressions are the non-obvious case
A plain $ would break writes:
count.set(5); // $(count).set(5) -> (0).set(5) -> crash
Unwrapping is exactly wrong when the property belongs to the signal rather than to
its value. So member reads use $m, which decides on the key:
count.set(5); // -> the signal (set is the signal's)
todos.filter(fn); // -> the array (filter is the value's)
user.name; // -> the object (name is the value's)
The signal-owned keys are set, subscribe, value and peek. Everything else
resolves to the value.
map deliberately resolves to the value, not the signal. During compilation a
signal's array is handed out as a recording proxy whose map builds a dynamic list
when given a key and behaves as Array.prototype.map otherwise. Routing map through
the signal would take that decision away from the one place with enough context to make
it.
The type side
The rewrite makes the code work. It does not make it type-check, and a framework
that told you to write something tsc rejects is the worst of both.
So Signal<T> is declared as T & Ops<T> — the value's type, intersected with the
signal's methods.
That intersection was rejected once, for a real reason: an intersection containing
number is comparable to a number literal, so count === 7 would type-check and be
false for ever. The transform is what changes the answer — $(count) === 7 is simply
correct — so the type is no longer promising something the runtime fails to deliver.
That dependency runs one way and is worth stating plainly: the type is only honest
where the rewrite runs. Under windows/, it does. In framework code it does not,
which is why Ops still carries .value and why src/ reads through it.
Collecting reads
An inline expression in JSX reaches the generated artifact as text, and every signal
named in that text needs an import. computed already tracks dependencies — but only
well enough to invalidate: a signal adds the listener to its own subscribers and never
reports itself, so there is no list to read back.
$ is the one door every rewritten read passes through, so it is the only place that
can answer. collectReads(fn) turns on collection, runs the expression, and returns the
set.
That is also why an inline expression can only name module exports: the artifact has to import each signal by name, and a local has none.
What this does not fix
The rewrite makes reads work. It does not make a computed created inside a component
placeable, it does not give a local a name, and it does not make an item template
capable of holding a conditional. Those all come from the same deeper constraint —
components are erased, so anything the runtime reaches must have a name at build time.
Verifying it
bun test src/compiler/reactive-transform.test.ts
bun test src/compiler/reactive-runtime.test.ts
bun run characterize # compiled output frozen as goldens
characterize is the one that matters when changing the transform: it freezes the
compiler's output so a refactor is provably behaviour-preserving.