Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Caveats

Return payloads across serialization boundaries

Return the payload, not the value. A generated client derives its response type from the handler, so a Val there arrives on the other side already typed as one, without having passed through the seal.

app.get("/user/:id", (c) => {
  const body: PayloadOf<User> = user; // the brand drops, the object is the same one
  return c.json(body);
});

Now the other side cannot use what arrives until it seals it:

const plain = await res.json(); // the generated client types this as PayloadOf<User>
const bad: User = plain; // type error: the brand is missing
const user = User(plain); // sealed, and now it is one

PayloadOf<V> removes the brand from the type, not from the value, so it costs nothing at run time. Val.unwrap copies and drops readonly too, which a request body does not need. Both also remove the brand of a nested Val.

Persistence helpers can create the same hole. Jotai’s atomWithStorage, for example, parses stored JSON and returns it as the type inferred from its initial value. Store a PayloadOf<User>, then seal it after reading.

Seal at the boundary, because the two sides deploy separately. The value was sealed by whichever build the server is running, and that seal may be older than yours.

Generic object utilities can bypass readonly and sealing

Object.assign accepts a readonly object as its target, so this passes the type checker:

Object.assign(user, { name: "mallory" });

Object.defineProperty and Reflect.set have the same problem. Valof freezes values in development, so these calls throw there. Production skips the freeze, and they mutate the Val.

Other utilities return a new object but preserve the input type. Immer, for example, makes a readonly input writable inside a callback:

const changed = produce(user, (draft) => {
  draft.name = "mallory";
}); // User

The result is still typed as User, although its seal never saw the change. Use User.patch to derive a value instead:

const changed = User.patch(user, { name: "mallory" });

When an API mutates its input, pass it Val.unwrap(user). When it returns a new payload, pass that payload to User or User.seal.

A __proto__ key survives sealing

A __proto__ key survives. It is a legal JSON key, and round-tripping JSON takes priority, so sealing keeps it as an own property rather than dropping data. That is inert inside a value, but not in code that merges a payload with Object.assign or a recursive merge. There, assigning the key sets a prototype instead of copying it. Sanitize untrusted input yourself.

Deeply nested payloads can overflow the stack

Copying and comparing are both recursive, so a payload a few thousand levels deep, or a cyclic one, throws a RangeError. Handle that error when sealing or comparing values from untrusted input.