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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased

- Add `optional()` field wrapper for optional (possibly-absent) fields.
Supports sentinel-value and predicate-function presence strategies.
The writability of the returned descriptor matches the wrapped descriptor.
- Extend `string()` with a dynamic-length overload: pass
`{ length: propertyName | (dv) => number }` as the second argument to create
a read-only variable-length string field.
- Extend `typedArray()` `length` option to also accept a
`(dv: DataView) => number` function in addition to a number or property name.

## 0.16.1 — 2026-04-02

- Align release publishing so npm and JSR stay version-synchronized.
Expand Down
106 changes: 106 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,109 @@ for (const dish of myMenu) {
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.

# Optional and variable-length fields

## Optional fields — `optional(descriptor, presence)`

Wrap any field descriptor with `optional()` to make it return `null` when the
field is absent. Two presence strategies are supported:

### Sentinel value

A field is absent when its stored value equals the sentinel (compared via
`Object.is`). Writing `null` stores the sentinel.

```js
import { defineStruct, optional, u16, u32 } from "@rotu/structview"

// 0xFFFF is the conventional "not present" marker for a u16
const Msg = defineStruct({
id: u16(0),
extra: optional(u32(4), { sentinel: 0xffffffff }),
})

const msg = Msg.alloc({ byteLength: 8 })
msg.id = 1
msg.extra = null // writes 0xffffffff into bytes 4-7
console.log(msg.extra) // → null

msg.extra = 99
console.log(msg.extra) // → 99
```

### Predicate function

A field is absent when a `(dv: DataView) => boolean` function returns `false`.
Setting to `null` is a no-op; the presence flag must be managed separately.

```js
const Packet = defineStruct({
flags: u8(0),
payload: optional(u32(4), (dv) => (dv.getUint8(0) & 0x01) !== 0),
})

const pkt = Packet.alloc({ byteLength: 8 })
console.log(pkt.payload) // → null (flag bit not set)

pkt.flags = 1
pkt.payload = 42
console.log(pkt.payload) // → 42
```

The returned descriptor is **writable when the wrapped descriptor is writable**,
and **read-only when the wrapped descriptor is read-only** (e.g. `substruct`,
`typedArray`, or `fromDataView` without a setter).

## Variable-length strings — `string(offset, { length })`

Pass an options object as the second argument to create a **read-only**
variable-length string field. The byte length can be a property name on the
struct or a function of the `DataView`.

```js
import { defineStruct, string, u8 } from "@rotu/structview"

const Frame = defineStruct({
name_len: u8(0),
// byte length is read from the `name_len` field at access time
name: string(1, { length: "name_len" }),
})
```

Or with a function:

```js
const Frame = defineStruct({
name: string(1, { length: (dv) => dv.getUint8(0) }),
})
```

> **Note:** Variable-length string fields are read-only. To write a
> variable-length string, update the backing buffer directly (e.g. via a
> `typedArray` or `fromDataView` with a custom setter).

## Variable-length typed arrays — `typedArray(offset, { species, length })`

The existing `typedArray` helper already supports a numeric length and a
property-name length. It now also accepts a `(dv: DataView) => number`
function:

```js
import { defineStruct, typedArray, u8 } from "@rotu/structview"

const Blob = defineStruct({
count: u8(0),
values: typedArray(4, {
species: Float32Array,
length: (dv) => dv.getUint8(0),
}),
})

const blob = Blob.alloc({ byteLength: 20 })
blob.count = 3
blob.values[0] = 1.5
blob.values[1] = 2.5
blob.values[2] = 3.5
```

144 changes: 127 additions & 17 deletions fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,26 +233,61 @@ export function f64(fieldOffset: number): StructPropertyDescriptor<number> {
export function string(
fieldOffset: number,
byteLength: number,
): StructPropertyDescriptor<string>
/**
* Field for a UTF-8 string whose byte length is determined at read time.
*
* @remarks The returned descriptor is read-only because writing a string of
* variable size requires external coordination (e.g. also updating the length
* field). Use `fromDataView` for a writable custom implementation.
*
* @param fieldOffset - Byte offset of the string within the struct.
* @param options.length - A property name on the struct whose value gives the
* byte length, or a function `(dv: DataView) => number` that computes it.
*/
export function string(
fieldOffset: number,
options: { readonly length: string | ((dv: DataView) => number) },
): StructPropertyDescriptor<string> & ReadOnlyAccessorDescriptor<string>
export function string(
fieldOffset: number,
arg: number | { readonly length: string | ((dv: DataView) => number) },
): StructPropertyDescriptor<string> {
const TEXT_DECODER = new TextDecoder()
const TEXT_ENCODER = new TextEncoder()
if (typeof arg === "number") {
const byteLength = arg
return {
get() {
const str = TEXT_DECODER.decode(
structBytes(this, fieldOffset, fieldOffset + byteLength),
)
// trim all trailing null characters
return str.replace(/\0+$/, "")
},
set(value) {
const bytes = structBytes(
this,
fieldOffset,
fieldOffset + byteLength,
)
bytes.fill(0)
TEXT_ENCODER.encodeInto(value, bytes)
},
}
}
const { length } = arg
return {
get() {
const dv = structDataView(this)
const len: number = typeof length === "string"
? (Reflect.get(this, length) as number)
: length(dv)
const str = TEXT_DECODER.decode(
structBytes(this, fieldOffset, fieldOffset + byteLength),
structBytes(this, fieldOffset, fieldOffset + len),
)
// trim all trailing null characters
return str.replace(/\0+$/, "")
},
set(value) {
const bytes = structBytes(
this,
fieldOffset,
fieldOffset + byteLength,
)
bytes.fill(0)
TEXT_ENCODER.encodeInto(value, bytes)
},
}
}

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

/**
* Define a descriptor based on a dataview of the struct
* @param fieldGetter function which, given a dataview, returns the field value
* @param fieldSetter optional function which, given a dataview and a value, sets the field value
* @returns an enumerable property descriptor; readonly if no setter is provided
* Wrap a field descriptor to make it optional, returning `null` when the field
* is considered absent.
*
* The returned descriptor inherits the writability of the wrapped descriptor:
* - If `descriptor` has a setter, the returned descriptor also has a setter.
* - If `descriptor` has no setter (read-only), the returned descriptor is
* read-only too.
*
* Two presence strategies are supported:
*
* **Sentinel value** — the field is absent when its binary value equals the
* sentinel (compared via `Object.is`). Setting the property to `null` writes
* the sentinel back into the buffer.
*
* ```ts
* const Cls = defineStruct({
* value: optional(u16(0), { sentinel: 0xffff }),
* })
* ```
*
* **Predicate function** — the field is absent when the predicate returns
* `false`. Setting the field to a non-null value writes it normally;
* setting to `null` is a no-op.
*
* ```ts
* const Cls = defineStruct({
* flags: u8(0),
* value: optional(u32(4), (dv) => (dv.getUint8(0) & 0x01) !== 0),
* })
* ```
*/
export function optional<T>(
descriptor: StructPropertyDescriptor<T> & { set(t: T): undefined },
presence: ((dv: DataView) => boolean) | { readonly sentinel: T },
): StructPropertyDescriptor<T | null>
export function optional<T>(
descriptor: StructPropertyDescriptor<T>,
presence: ((dv: DataView) => boolean) | { readonly sentinel: T },
): StructPropertyDescriptor<T | null> & ReadOnlyAccessorDescriptor<T | null>
export function optional<T>(
descriptor: StructPropertyDescriptor<T>,
presence: ((dv: DataView) => boolean) | { readonly sentinel: T },
): StructPropertyDescriptor<T | null> {
if (typeof presence === "function") {
const result: StructPropertyDescriptor<T | null> = {
get() {
if (!presence(structDataView(this))) return null
return descriptor.get!.call(this) as T
},
}
if (typeof descriptor.set === "function") {
result.set = function (value: T | null) {
if (value !== null) {
descriptor.set!.call(this, value)
}
}
}
return result
}
const { sentinel } = presence
const result: StructPropertyDescriptor<T | null> = {
get() {
const value = descriptor.get!.call(this) as T
return Object.is(value, sentinel) ? null : value
},
}
if (typeof descriptor.set === "function") {
result.set = function (value: T | null) {
descriptor.set!.call(this, value === null ? sentinel : value)
}
}
return result
}

export function fromDataView<T>(
fieldGetter: (dv: DataView) => T,
fieldSetter: (dv: DataView, value: T) => void,
Expand Down Expand Up @@ -351,8 +455,12 @@ export function substruct<
export function typedArray<T>(
fieldOffset: number,
kwargs: {
/** length or property name for the length of the array */
readonly length: number | string | undefined
/** length, property name, function, or undefined (fill remaining buffer) */
readonly length:
| number
| string
| ((dv: DataView) => number)
| undefined
/** TypedArray constructor */
readonly species: TypedArraySpecies<T>
},
Expand All @@ -370,6 +478,8 @@ export function typedArray<T>(
lengthValue = length
} else if (typeof length === "string") {
lengthValue = Reflect.get(this, length)
} else if (typeof length === "function") {
lengthValue = length(dv)
}
return new species(
dv.buffer,
Expand Down
Loading