Working with the data model
A surface's data lives in a plain object, and you read and write it through three pointer helpers plus a resolver. This guide covers all four.
The data model is just a Record<string, unknown>. A2UI components reference into it by JSON-Pointer-style path; @threadplane/a2ui gives you the helpers to read, write, and resolve against it.
The pointer helpers
getByPointer, setByPointer, and deleteByPointer all take a model and a pointer string (/user/name, /items/1).
getByPointer walks the path and returns the value, or undefined if any segment is missing:
import { getByPointer } from '@threadplane/a2ui';
getByPointer({ items: ['a', 'b', 'c'] }, '/items/1'); // "b"setByPointer writes immutably. It returns a clone with the change applied; the original is untouched.
import { setByPointer } from '@threadplane/a2ui';
const original = { user: { name: 'Alice', age: 30 } };
const next = setByPointer(original, '/user/name', 'Bob');
next.user.name; // "Bob"
original.user.name; // "Alice" — unchangedIt also creates intermediate objects along the way, so you don't have to pre-build nesting:
setByPointer({}, '/a/b/c', 42); // { a: { b: { c: 42 } } }deleteByPointer removes a key, again immutably:
import { deleteByPointer } from '@threadplane/a2ui';
deleteByPointer({ a: 1, b: 2 }, '/a'); // { b: 2 }If the parent of the target doesn't exist, deleteByPointer returns the original model unchanged rather than fabricating a path to delete from.
One v0.9-specific rule: deleting an array index does not splice. The index is set to undefined and the array's length is preserved, so sibling indices stay stable for other bindings:
deleteByPointer({ items: ['a', 'b', 'c'] }, '/items/1');
// { items: ['a', undefined, 'c'] } — length still 3These helpers use JSON-Pointer-style syntax but do not implement RFC 6901's ~0 / ~1 unescaping. A path is split on / and the segments are used as literal keys. So keys that themselves contain / or ~ aren't addressable — there's no escape sequence to reach them.
Applying updateDataModel envelopes
The pointer helpers are the primitives. The most common real task is one level up: applying an updateDataModel envelope to the model. In v0.9 the envelope's value is plain JSON — no typed entry wrappers — so applying it is a direct mapping onto the helpers:
valuepresent,pathpresent —setByPointer(model, path, value)replaces (or creates) the data atpath.valuepresent,pathomitted or'/'— the whole model is replaced byvalue.valueomitted — the key atpathis deleted (deleteByPointer); with nopath, the model resets to{}.
import { setByPointer, deleteByPointer } from '@threadplane/a2ui';
import type { A2uiUpdateDataModel } from '@threadplane/a2ui';
function applyUpdateDataModel(
model: Record<string, unknown>,
update: A2uiUpdateDataModel,
): Record<string, unknown> {
const path = update.path && update.path !== '/' ? update.path : undefined;
if (!('value' in update) || update.value === undefined) {
return path ? deleteByPointer(model, path) : {};
}
if (!path) return update.value as Record<string, unknown>;
return setByPointer(model, path, update.value);
}
applyUpdateDataModel(
{},
{ surfaceId: 's1', path: '/customer', value: { name: 'Ada' } },
);
// { customer: { name: 'Ada' } }Nesting is just JSON: value: { name: 'Ada', address: { city: 'London' } } writes the nested object as-is. There is no per-entry typing to reduce — the wire value is already the model shape.
Resolving dynamic values
resolveDynamic collapses a component's prop to a concrete value against the model. The order is fixed:
null/undefinedpass through as-is.- Arrays are mapped recursively — each element resolved in turn.
- A
{ call }function-call value executes through the function registry passed toresolveDynamic(standard set:formatString,formatNumber,formatCurrency,formatDate,pluralize,and,or,not); args resolve recursively, so they may be bindings or nested calls. Without a registry, or for unknown names, the value resolves toundefined. Checked before path refs so a call'sargsnever masquerade as a binding. - A
{ path }reference reads from the model. - Anything else — a bare string, number, boolean, or plain object — passes through unchanged. Bare values are the v0.9 literal form; there are no wrapper objects.
import { resolveDynamic } from '@threadplane/a2ui';
const model = { name: 'Brian', count: 7, active: true, tags: ['a', 'b'] };
resolveDynamic('hello', model); // "hello"
resolveDynamic({ path: '/name' }, model); // "Brian"
resolveDynamic({ path: '/tags/0' }, model); // "a"
resolveDynamic({ path: '/missing' }, model); // undefinedA missing path resolves to undefined, never an error. That keeps a half-streamed surface renderable while data is still arriving.
Scopes and template children
How do you resolve a relative path, like inside a repeated template row?
resolveDynamic takes an optional third argument, an A2uiScope:
export interface A2uiScope {
basePath: string;
item: unknown;
}Path resolution depends on the leading slash:
- An absolute path (
/name) always resolves from the model root, scope or not. - A relative path (
name) resolves againstscope.basePathwhen a scope is given.
resolveDynamic({ path: 'name' }, model, { basePath: '', item: undefined }); // "Brian"With basePath: '', the relative path name resolves to /name. That's the lever children templates pull. When a container's children is { "path": "/items", "componentId": "tpl" }, it repeats the template component over the array at /items and resolves each instance's props with a per-item scope:
items.forEach((_, i) => {
const scope = { basePath: `/items/${i}`, item: items[i] };
// a child prop of { path: 'label' } now resolves to /items/{i}/label
resolveDynamic({ path: 'label' }, model, scope);
});A2uiScope carries an item field, but the resolver only reads basePath to rewrite relative paths. item is typed for callers that want the bound element on hand, yet resolveDynamic itself never touches it. Don't expect setting item to change resolution.
Next
- Validating and adapting an A2UI stream — guards, test payloads, and wiring a custom renderer.