Skip to content

Why accessors and not a Proxy

This page is about the TypeScript library.

zone.ceiling_height is a real property. It is not a Proxy trap, and the difference is not academic.

The obvious translation, and why it was rejected

The Python library resolves zone.ceiling_height at runtime through IDFObject.__getattr__: the attribute does not exist, Python calls a hook, and the hook consults the schema. The mechanical translation of that to JavaScript is a Proxy, and a Proxy was rejected for two reasons.

Property access through a trap is not free. Reading an ordinary property is an inline cache hit; reading through a proxy is a call into a trap, every time. A parse creates one object per record in the file and then reads every field again to write the document back out, so the cost lands on the whole document rather than on a few call sites. Neither library has published a measurement of the difference, and the decision was made on the mechanism rather than on a number.

A trap is not what the generated types describe. The .d.ts interfaces in each version's type package describe named properties. Real accessors on a prototype are exactly that; a proxy's properties come into existence only when something asks for them, so the type would have to be asserted separately from the object that carries it, and the assertion would be the thing that goes stale. Python reaches the same destination by a different route, pairing the __getattr__ hook with generated .pyi stubs: see type-safe development and static types generated from the schema.

What is done instead

Each object type gets one prototype, built once by ObjectShape, carrying Object.defineProperty accessors for every field in the schema. Every instance of that type shares it. Reads are ordinary monomorphic property lookups, and the generated .d.ts interfaces describe them statically.

Shapes are keyed by the schema definition object rather than by type name. Because the bundle is content-addressed, Zone in 9.4.0 and Zone in 26.1.0 are the same frozen definition, so they share one shape and one prototype. A document holding objects from two EnergyPlus versions stays monomorphic without anyone arranging for it.

Why the setter matters

The reference graph is live. Renaming an object rewrites every field elsewhere that pointed at the old name:

const zone = doc.require('Zone', 'SPACE1-1');
zone.name = 'Open Office';
surface.zone_name; // 'Open Office'

Nothing was called to make that happen. There is no update(), no rebuildIndex(), and no invalidation step for a caller to forget. The write went through the accessor's setter, and the setter is where the graph edge moves.

That is the real argument for defining accessors rather than storing plain data properties. A plain property would be faster still and would leave the graph stale on every write, which is a correctness problem rather than a performance one. Extensible groups are covered too: ZoneList, Branch, and the supply and return paths carry all of their references inside repeat groups, so a graph that ignored those would let a rename quietly produce a broken model.