firsthand

ADR-0005: Reactive props via accessor descriptors, immutability at the type level

Status: accepted; the destructuring half was reversed after use (see below)

Problem

Props must be real JavaScript values passed by reference (no serialisation, no copies, identical object identity), must be able to update reactively without re-initialising the child, must be readonly from the child’s perspective, and must not silently become stale when destructured.

Constraints

Options considered

  1. Plain object snapshot per update, child re-initialised. That is the React model; it is excluded by the requirements.
  2. Props object rebuilt per change, child keeps running. Requires the child to hold a live reference to a mutable props object; mutating the object the child holds works, but every read site must be reactive anyway, so nothing is gained over accessors, and object mutation must still notify.
  3. Proxy over a reactive record. Uniform, supports in/ownKeys, but adds a trap on every property access in the hottest path of component code, and proxies are not free in any engine. Identity of the props object itself becomes a proxy, which leaks into === comparisons and devtools.
  4. Accessor descriptors generated by the compiler. Dynamic props become getters over the parent’s expression; static props become plain data properties. Reading a prop inside a reactive scope subscribes to whatever the parent expression reads.

Chosen design

Option 4.

<UserCard user={currentUser.value} label="hi" />

emits

createComponent(UserCard, {
  get user() {
    return currentUser.value;
  },
  label: 'hi',
});

No setters are defined, so the props object has no writable top-level properties. The values themselves are the parent’s own references.

Immutability is enforced by ReadonlyProps<T> (deep-readonly mapped type), by the absence of setters, and by an ESLint rule that flags mutation through props. The README states explicitly that runtime deep immutability is not enforced and why.

Destructuring is rewritten into live reads:

const Row = component<RowProps>(({ todo, count = 0, ...rest }) => (
  <li {...rest}>
    {todo.title}: {count}
  </li>
));

compiles to

const Row = component<RowProps>((props) => {
  const rest = rest_(props, ['todo', 'count']);
  return (
    <li {...rest}>
      {props.todo.title}: {props.count ?? 0}
    </li>
  );
});

Every reference becomes a property read on the props object. A default is re-applied on every read, which is what the language means by a default. A rest element becomes one object of getters that delegate back to the props, so forwarding {...rest} forwards live values rather than a snapshot.

The rewrite is a rename, not a wrapper: no closure, no proxy, nothing allocated per read, and the emitted code is what a developer would have written by hand.

What cannot be rewritten soundly is still an error naming the reason: assigning to a destructured prop (props are readonly), a computed key (it would have to be resolved at setup time, which is the snapshot this avoids), an array pattern (props are an object), and any pattern the rewrite cannot follow.

A default is re-applied on every read, which is what the language does, and it applies to undefined alone — also what the language does. Amended (0.12.0): the emitted form was props.count ?? 0, and ?? answers for null as well, so a parent passing null got the default back instead of the null it asked for. null is what an API returns for “known to be empty”, which is a different answer from “not given”, and a component could not tell them apart. It is now props.count === undefined ? 0 : props.count. Two reads of an accessor rather than one, which is the price of meaning the same thing the language means — and the compiler test asserted the wrong output verbatim, so the behaviour was blessed rather than merely untested.

This reverses an earlier decision

The first version of this ADR rejected destructuring outright, on the argument that a rewrite which “silently stops applying at the edge of what it can prove” is worse than an error. That argument was wrong about where the edge is. The patterns people actually write — plain keys, renames, defaults, nesting, rest — are all rewritable, and the ones that are not are recognisable syntactically rather than by analysis, so the error is exact rather than approximate.

The original reasoning also ignored the cost it was imposing: ({ todo }) => is the shape people reach for, and refusing it made every component read worse in order to protect against a case the compiler can see coming.

Performance implications

Memory implications

One props object per component instance, with one accessor descriptor per dynamic prop, created once. Nothing is allocated when a prop value changes.

DX implications

Rejected alternatives

Proxies (access cost and identity leakage), copies or freezing (violates identity / mutates foreign objects), re-initialising the child (React model, excluded), and silently tolerating destructuring (the exact stale-value class of bug this framework exists to remove).