Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Architecture

This document explains the key design decisions behind structview and the
trade-offs considered.

## Why property descriptors?

structview uses `Object.defineProperties()` on the prototype to expose binary
fields as getter/setter pairs. Each field factory (`u8`, `f32`, `string`, …)
returns a standard `PropertyDescriptor`, and `defineStruct()` installs them on
an anonymous subclass of `Struct`.

Alternatives considered:

### Proxy-based approach

A `Proxy` wrapper could intercept property access and dispatch to the underlying
`DataView`. This would make `{...struct}` and `Object.keys()` work transparently
because the proxy's `ownKeys` / `getOwnPropertyDescriptor` traps can advertise
the fields as own properties.

Downsides:

- **Performance.** Proxy property access is roughly 5–10× slower than a
prototype getter in V8. For a library whose main value proposition is
zero-copy views of binary data, this is a significant tax.
- **TypeScript ergonomics.** Typing a Proxy so that each field has the correct
type requires mapped-type gymnastics and loses IntelliSense features like
"Go to Definition" on individual fields.
- **Class integration.** Proxies don't compose naturally with `class` syntax,
`instanceof`, or `super`. Users couldn't subclass a struct to add domain
methods without additional boilerplate.
- **Identity.** A proxy wrapping a plain target object complicates `===`
comparisons and WeakMap keying.

### Instance descriptors (defineProperty on each instance)

Instead of sharing descriptors on the prototype, each constructor call could
install them on the instance. That would make `{...struct}` work because spread
only copies own properties.

Downsides:

- **Memory.** Every instance allocates its own set of descriptor objects.
For struct arrays with thousands of elements, this adds significant GC
pressure.
- **Startup cost.** `Object.defineProperties` on each instance is measurably
slower than a single prototype setup.

### Conclusion

Prototype property descriptors offer the best balance of performance, memory
efficiency, TypeScript inference, and composability with the class system.
The main ergonomic gap—`JSON.stringify()` and spread not reflecting inherited
fields—is addressed by providing a `toJSON()` method on `Struct`.

## What was done well

1. **Symbol-keyed internal state.** The `DataView` backing store lives behind
`Symbol.for("Struct.dataview")`, so user field names never collide with
internal bookkeeping.
2. **Composable field factories.** Each factory (`u8`, `f32`, `string`, …) is a
pure function returning a standard descriptor. Users can write their own
factories (via `fromDataView`) without touching library internals.
3. **TypeScript integration.** `defineStruct` preserves full type inference—
field types, readonly inference for getter-only descriptors, and constructor
signatures—without requiring separate type declarations.
4. **Zero-copy views.** Struct instances and substructs share the same
`ArrayBuffer`. Mutations are immediately visible across all views.

## What was improved

1. **`toJSON()` on `Struct`.** `JSON.stringify(struct)` now works as expected,
serializing all enumerable inherited fields. Nested substructs serialize
recursively.
2. **Gotcha documentation.** The README gotcha about spread/JSON has been updated
to note that `JSON.stringify` now works via `toJSON()`, while spread syntax
still requires a manual `Object.assign({}, ...)` pattern.

## Remaining limitations

- **Spread syntax** (`{...struct}`) still copies only own enumerable properties.
Since fields live on the prototype, spread produces an empty object. Use
`struct.toJSON()` to obtain a plain-object snapshot, or
`Object.assign({}, struct.toJSON())` if you need a spreadable copy.
- **`structuredClone()`** does not invoke `toJSON()` and will not preserve field
values. Clone the underlying buffer instead.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.17.0

- Add `toJSON()` method to `Struct` so `JSON.stringify(struct)` works out of the
box, serializing all enumerable inherited fields.
- Add `ARCHITECTURE.md` documenting design trade-offs (property descriptors vs
proxies vs instance descriptors).

## 0.16.1 — 2026-04-02

- Align release publishing so npm and JSR stay version-synchronized.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,6 @@ for (const dish of myMenu) {
3. Be careful using `TypedArray`s. They have an alignment requirement relative
to their underlying `ArrayBuffer`.
4. `Struct` classes define properties on the prototype, _not_ on the instance.
That means spread syntax (`x = {...s}`) and `JSON.stringify(s)` will _not_
reflect inherited fields.
That means spread syntax (`x = {...s}`) will _not_ reflect inherited fields.
`JSON.stringify(s)` _does_ work because `Struct` provides a `toJSON()`
method. To get a spreadable plain object, use `s.toJSON()`.
12 changes: 12 additions & 0 deletions core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ export class Struct {
return new this({ buffer })
}

/**
* Serialize enumerable fields to a plain object.
* Enables `JSON.stringify(struct)` to include inherited prototype fields.
*/
toJSON(): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const key in this) {
result[key] = (this as Record<string, unknown>)[key]
}
return result
}

get [Symbol.toStringTag](): string {
return Struct.name
}
Expand Down
36 changes: 36 additions & 0 deletions mod_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,42 @@ test("fromDataView with setter is writable and enumerable", () => {
assert(keys.includes("val"))
})

test("toJSON with primitive fields", () => {
class S extends defineStruct({
x: u8(0),
y: f32(4),
name: string(8, 5),
}) {}
const buf = new Uint8Array(13)
const s = new S(buf)
s.x = 42
s.y = 1.5
s.name = "hello"
deepStrictEqual(s.toJSON(), { x: 42, y: 1.5, name: "hello" })
deepStrictEqual(JSON.parse(JSON.stringify(s)), { x: 42, y: 1.5, name: "hello" })
})

test("toJSON with substruct", () => {
const Point = defineStruct({ x: f32(0), y: f32(4) })
const Rect = defineStruct({
origin: substruct(Point, 0, 8),
size: substruct(Point, 8, 8),
})
const buf = new Float32Array([1, 2, 3, 4])
const rect = new Rect(buf)
const json = JSON.parse(JSON.stringify(rect))
deepStrictEqual(json, {
origin: { x: 1, y: 2 },
size: { x: 3, y: 4 },
})
})

test("toJSON on empty struct", () => {
const s = new Struct({ buffer: new ArrayBuffer(0) })
deepStrictEqual(s.toJSON(), {})
deepStrictEqual(JSON.stringify(s), "{}")
})

function hexToUint8Array(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new TypeError("Hex input must have an even length")
Expand Down