Reducer-style stateful contracts with deep-immutable reads.
import { Define, Loader } from '@neabyte/typebox'
const app = Loader({
counter: Define.state(0, (count, amount: number) => count + amount)
})
app.counter.get() // 0
app.counter(5) // 5
app.counter(2) // 7
app.counter.get() // 7A stateful contract is built by Define.state. Once Loader activates it, the entry becomes a StateHandle: a function with a get() method. See StateHandle for the type.
The stepFn returns a new value instead of changing the current one in place, so the handle stays in control of what is stored.
Advance the state. The handle runs the step with the current value and the payload, stores the returned value, and returns it. A two-argument step requires a payload, and a one-argument step takes none.
const app = Loader({
counter: Define.state(0, (count, amount: number) => count + amount),
ticks: Define.state(0, (count) => count + 1)
})
app.counter(5) // 5, payload required
app.ticks() // 1, no payload
app.ticks() // 2Read the current state without changing it. The return type is DeepImmutable, so callers cannot mutate the snapshot and leak changes back into the store.
const app = Loader({
cart: Define.state(
{ items: 0, total: 0 },
(cartState, price: number) => ({ items: cartState.items + 1, total: cartState.total + price })
)
})
app.cart(100)
app.cart(250)
app.cart.get() // { items: 2, total: 350 }The handle keeps its value in a closure. Because the step returns a new value rather than changing the current one, a throw inside a step leaves the previous state in place.
const app = Loader({
safe: Define.state(10, (count, amount: number) => {
if (amount < 0) {
throw new Error('no negatives')
}
return count + amount
})
})
app.safe(5) // 15
try {
app.safe(-1) // throws, state unchanged
} catch {
// ignore
}
app.safe.get() // 15If a step returns the same reference it received (checked with Object.is), the stored value is left as is and is not re-frozen.
The seed passed to Define.state belongs to the caller. When the handle comes to life it clones that seed with structuredClone into owned state before freezing, so activating a contract never freezes or mutates the caller's object. A seed that cannot be cloned is rejected with TypeError('stateful initial state must be structured cloneable') rather than shared.
const seed = { items: 0 }
const app = Loader({
cart: Define.state(seed, (cartState, count: number) => ({ items: cartState.items + count }))
})
app.cart(3)
seed.items // 0, original seed untouchedFreezing is deep and collection-aware. A Map or Set is rebuilt with frozen members and returned as a readonly view whose mutating methods (set, add, delete, clear) throw, everything else is frozen with Object.freeze in place, and typed array views are left as is. See DeepImmutable for the matching type.