Skip to content

Commit d4695a3

Browse files
Copilotrotu
andauthored
Add optional() wrapper and variable-length string/typedArray support
Agent-Logs-Url: https://github.com/rotu/structview/sessions/3465639d-8c89-4269-94e2-6dbf1256113f Co-authored-by: rotu <119948+rotu@users.noreply.github.com>
1 parent b0ee5ca commit d4695a3

4 files changed

Lines changed: 415 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Add `optional()` field wrapper for optional (possibly-absent) fields.
6+
Supports sentinel-value and predicate-function presence strategies.
7+
The writability of the returned descriptor matches the wrapped descriptor.
8+
- Extend `string()` with a dynamic-length overload: pass
9+
`{ length: propertyName | (dv) => number }` as the second argument to create
10+
a read-only variable-length string field.
11+
- Extend `typedArray()` `length` option to also accept a
12+
`(dv: DataView) => number` function in addition to a number or property name.
13+
314
## 0.16.1 — 2026-04-02
415

516
- Align release publishing so npm and JSR stay version-synchronized.

README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,109 @@ for (const dish of myMenu) {
108108
4. `Struct` classes define properties on the prototype, _not_ on the instance.
109109
That means spread syntax (`x = {...s}`) and `JSON.stringify(s)` will _not_
110110
reflect inherited fields.
111+
112+
# Optional and variable-length fields
113+
114+
## Optional fields — `optional(descriptor, presence)`
115+
116+
Wrap any field descriptor with `optional()` to make it return `null` when the
117+
field is absent. Two presence strategies are supported:
118+
119+
### Sentinel value
120+
121+
A field is absent when its stored value equals the sentinel (compared via
122+
`Object.is`). Writing `null` stores the sentinel.
123+
124+
```js
125+
import { defineStruct, optional, u16, u32 } from "@rotu/structview"
126+
127+
// 0xFFFF is the conventional "not present" marker for a u16
128+
const Msg = defineStruct({
129+
id: u16(0),
130+
extra: optional(u32(4), { sentinel: 0xffffffff }),
131+
})
132+
133+
const msg = Msg.alloc({ byteLength: 8 })
134+
msg.id = 1
135+
msg.extra = null // writes 0xffffffff into bytes 4-7
136+
console.log(msg.extra) // → null
137+
138+
msg.extra = 99
139+
console.log(msg.extra) // → 99
140+
```
141+
142+
### Predicate function
143+
144+
A field is absent when a `(dv: DataView) => boolean` function returns `false`.
145+
Setting to `null` is a no-op; the presence flag must be managed separately.
146+
147+
```js
148+
const Packet = defineStruct({
149+
flags: u8(0),
150+
payload: optional(u32(4), (dv) => (dv.getUint8(0) & 0x01) !== 0),
151+
})
152+
153+
const pkt = Packet.alloc({ byteLength: 8 })
154+
console.log(pkt.payload) // → null (flag bit not set)
155+
156+
pkt.flags = 1
157+
pkt.payload = 42
158+
console.log(pkt.payload) // → 42
159+
```
160+
161+
The returned descriptor is **writable when the wrapped descriptor is writable**,
162+
and **read-only when the wrapped descriptor is read-only** (e.g. `substruct`,
163+
`typedArray`, or `fromDataView` without a setter).
164+
165+
## Variable-length strings — `string(offset, { length })`
166+
167+
Pass an options object as the second argument to create a **read-only**
168+
variable-length string field. The byte length can be a property name on the
169+
struct or a function of the `DataView`.
170+
171+
```js
172+
import { defineStruct, string, u8 } from "@rotu/structview"
173+
174+
const Frame = defineStruct({
175+
name_len: u8(0),
176+
// byte length is read from the `name_len` field at access time
177+
name: string(1, { length: "name_len" }),
178+
})
179+
```
180+
181+
Or with a function:
182+
183+
```js
184+
const Frame = defineStruct({
185+
name: string(1, { length: (dv) => dv.getUint8(0) }),
186+
})
187+
```
188+
189+
> **Note:** Variable-length string fields are read-only. To write a
190+
> variable-length string, update the backing buffer directly (e.g. via a
191+
> `typedArray` or `fromDataView` with a custom setter).
192+
193+
## Variable-length typed arrays — `typedArray(offset, { species, length })`
194+
195+
The existing `typedArray` helper already supports a numeric length and a
196+
property-name length. It now also accepts a `(dv: DataView) => number`
197+
function:
198+
199+
```js
200+
import { defineStruct, typedArray, u8 } from "@rotu/structview"
201+
202+
const Blob = defineStruct({
203+
count: u8(0),
204+
values: typedArray(4, {
205+
species: Float32Array,
206+
length: (dv) => dv.getUint8(0),
207+
}),
208+
})
209+
210+
const blob = Blob.alloc({ byteLength: 20 })
211+
blob.count = 3
212+
blob.values[0] = 1.5
213+
blob.values[1] = 2.5
214+
blob.values[2] = 3.5
215+
```
216+

fields.ts

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -233,26 +233,61 @@ export function f64(fieldOffset: number): StructPropertyDescriptor<number> {
233233
export function string(
234234
fieldOffset: number,
235235
byteLength: number,
236+
): StructPropertyDescriptor<string>
237+
/**
238+
* Field for a UTF-8 string whose byte length is determined at read time.
239+
*
240+
* @remarks The returned descriptor is read-only because writing a string of
241+
* variable size requires external coordination (e.g. also updating the length
242+
* field). Use `fromDataView` for a writable custom implementation.
243+
*
244+
* @param fieldOffset - Byte offset of the string within the struct.
245+
* @param options.length - A property name on the struct whose value gives the
246+
* byte length, or a function `(dv: DataView) => number` that computes it.
247+
*/
248+
export function string(
249+
fieldOffset: number,
250+
options: { readonly length: string | ((dv: DataView) => number) },
251+
): StructPropertyDescriptor<string> & ReadOnlyAccessorDescriptor<string>
252+
export function string(
253+
fieldOffset: number,
254+
arg: number | { readonly length: string | ((dv: DataView) => number) },
236255
): StructPropertyDescriptor<string> {
237256
const TEXT_DECODER = new TextDecoder()
238257
const TEXT_ENCODER = new TextEncoder()
258+
if (typeof arg === "number") {
259+
const byteLength = arg
260+
return {
261+
get() {
262+
const str = TEXT_DECODER.decode(
263+
structBytes(this, fieldOffset, fieldOffset + byteLength),
264+
)
265+
// trim all trailing null characters
266+
return str.replace(/\0+$/, "")
267+
},
268+
set(value) {
269+
const bytes = structBytes(
270+
this,
271+
fieldOffset,
272+
fieldOffset + byteLength,
273+
)
274+
bytes.fill(0)
275+
TEXT_ENCODER.encodeInto(value, bytes)
276+
},
277+
}
278+
}
279+
const { length } = arg
239280
return {
240281
get() {
282+
const dv = structDataView(this)
283+
const len: number = typeof length === "string"
284+
? (Reflect.get(this, length) as number)
285+
: length(dv)
241286
const str = TEXT_DECODER.decode(
242-
structBytes(this, fieldOffset, fieldOffset + byteLength),
287+
structBytes(this, fieldOffset, fieldOffset + len),
243288
)
244-
// trim all trailing null characters
245289
return str.replace(/\0+$/, "")
246290
},
247-
set(value) {
248-
const bytes = structBytes(
249-
this,
250-
fieldOffset,
251-
fieldOffset + byteLength,
252-
)
253-
bytes.fill(0)
254-
TEXT_ENCODER.encodeInto(value, bytes)
255-
},
256291
}
257292
}
258293

@@ -272,11 +307,80 @@ export function bool(fieldOffset: number): StructPropertyDescriptor<boolean> {
272307
}
273308

274309
/**
275-
* Define a descriptor based on a dataview of the struct
276-
* @param fieldGetter function which, given a dataview, returns the field value
277-
* @param fieldSetter optional function which, given a dataview and a value, sets the field value
278-
* @returns an enumerable property descriptor; readonly if no setter is provided
310+
* Wrap a field descriptor to make it optional, returning `null` when the field
311+
* is considered absent.
312+
*
313+
* The returned descriptor inherits the writability of the wrapped descriptor:
314+
* - If `descriptor` has a setter, the returned descriptor also has a setter.
315+
* - If `descriptor` has no setter (read-only), the returned descriptor is
316+
* read-only too.
317+
*
318+
* Two presence strategies are supported:
319+
*
320+
* **Sentinel value** — the field is absent when its binary value equals the
321+
* sentinel (compared via `Object.is`). Setting the property to `null` writes
322+
* the sentinel back into the buffer.
323+
*
324+
* ```ts
325+
* const Cls = defineStruct({
326+
* value: optional(u16(0), { sentinel: 0xffff }),
327+
* })
328+
* ```
329+
*
330+
* **Predicate function** — the field is absent when `(dv: DataView) => boolean`
331+
* returns `false`. Setting the field to a non-null value writes it normally;
332+
* setting to `null` is a no-op.
333+
*
334+
* ```ts
335+
* const Cls = defineStruct({
336+
* flags: u8(0),
337+
* value: optional(u32(4), (dv) => (dv.getUint8(0) & 0x01) !== 0),
338+
* })
339+
* ```
279340
*/
341+
export function optional<T>(
342+
descriptor: StructPropertyDescriptor<T> & { set(t: T): undefined },
343+
presence: ((dv: DataView) => boolean) | { readonly sentinel: T },
344+
): StructPropertyDescriptor<T | null>
345+
export function optional<T>(
346+
descriptor: StructPropertyDescriptor<T>,
347+
presence: ((dv: DataView) => boolean) | { readonly sentinel: T },
348+
): StructPropertyDescriptor<T | null> & ReadOnlyAccessorDescriptor<T | null>
349+
export function optional<T>(
350+
descriptor: StructPropertyDescriptor<T>,
351+
presence: ((dv: DataView) => boolean) | { readonly sentinel: T },
352+
): StructPropertyDescriptor<T | null> {
353+
if (typeof presence === "function") {
354+
const result: StructPropertyDescriptor<T | null> = {
355+
get() {
356+
if (!presence(structDataView(this))) return null
357+
return descriptor.get!.call(this) as T
358+
},
359+
}
360+
if (typeof descriptor.set === "function") {
361+
result.set = function (value: T | null) {
362+
if (value !== null) {
363+
descriptor.set!.call(this, value)
364+
}
365+
}
366+
}
367+
return result
368+
}
369+
const { sentinel } = presence
370+
const result: StructPropertyDescriptor<T | null> = {
371+
get() {
372+
const value = descriptor.get!.call(this) as T
373+
return Object.is(value, sentinel) ? null : value
374+
},
375+
}
376+
if (typeof descriptor.set === "function") {
377+
result.set = function (value: T | null) {
378+
descriptor.set!.call(this, value === null ? sentinel : value)
379+
}
380+
}
381+
return result
382+
}
383+
280384
export function fromDataView<T>(
281385
fieldGetter: (dv: DataView) => T,
282386
fieldSetter: (dv: DataView, value: T) => void,
@@ -351,8 +455,12 @@ export function substruct<
351455
export function typedArray<T>(
352456
fieldOffset: number,
353457
kwargs: {
354-
/** length or property name for the length of the array */
355-
readonly length: number | string | undefined
458+
/** length, property name, function, or undefined (fill remaining buffer) */
459+
readonly length:
460+
| number
461+
| string
462+
| ((dv: DataView) => number)
463+
| undefined
356464
/** TypedArray constructor */
357465
readonly species: TypedArraySpecies<T>
358466
},
@@ -370,6 +478,8 @@ export function typedArray<T>(
370478
lengthValue = length
371479
} else if (typeof length === "string") {
372480
lengthValue = Reflect.get(this, length)
481+
} else {
482+
lengthValue = length(dv)
373483
}
374484
return new species(
375485
dv.buffer,

0 commit comments

Comments
 (0)