PathObject provides a path-based approach to accessing and manipulating LiveObjects data structures. Instead of working with explicit object instances, you work with paths that resolve to objects dynamically at runtime.
A PathObject represents a path to a specific location within the channel object. When you call methods on a PathObject, they operate on whatever value exists at that path at the time the method is called.
Get a PathObject
Call channel.object.get() to obtain a PathObject for the root path "". This root PathObject always resolves to the LiveMap at the root of the channel object, which serves as the entry point for navigation.
1
2
3
4
5
// Get a PathObject for the channel object
const myObject = await channel.object.get();
// This PathObject has an empty path
console.log(myObject.path()); // ""Calling channel.object.get() implicitly attaches to the channel if not already attached. The returned promise resolves when the channel object data has been synchronized to the client.
A PathObject doesn't hold actual data - it holds a reference to a location. This makes PathObject references stable and reusable, even if the underlying object instances change:
1
2
3
4
5
6
7
8
9
10
11
12
// Obtain a PathObject at the 'visits' key
const visits = myObject.get('visits');
// Increment the LiveCounter stored at this path
await visits.increment(5);
// Someone replaces the LiveCounter instance stored at 'visits'
await myObject.set('visits', LiveCounter.create(0));
// The same PathObject can be used to increment the LiveCounter instance
// stored at the 'visits' key at the time the method is called
await visits.increment(1);A PathObject references a location rather than a specific instance, so you can safely store and reuse PathObject references throughout your application.
Type inference
When using TypeScript, you can provide type parameters to channel.object.get() to get rich type inference:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { type PathObject, LiveCounter, LiveMap } from 'ably/liveobjects';
type MyObject = {
visits: LiveCounter;
settings: LiveMap<{
theme: string;
notifications: boolean;
}>;
};
const myObject = await channel.object.get<MyObject>();
// TypeScript knows 'visits' is a LiveCounter
const visits: PathObject<LiveCounter> = myObject.get('visits');
await visits.increment(1); // Type-safe
// TypeScript knows the shape of 'settings'
const theme: PathObject<string> = myObject.get('settings').get('theme');
const value = theme.value(); // Returns string | undefinedSee the Typing documentation for more details on type safety.
Navigate a PathObject
PathObject provides methods to navigate through the channel object and inspect paths.
Navigate to child paths
Use the get(key) method to navigate to a child path. The returned PathObject is valid even if nothing exists at that path yet:
1
2
3
4
5
6
7
8
9
10
11
12
const myObject = await channel.object.get();
// Navigate to a child path
const settings = myObject.get('settings');
console.log(settings.path()); // "settings"
// Chain get() calls for deeper navigation
// get() never returns undefined, even if there is
// nothing at the specified path
const color = settings.get('theme').get('color');
console.log(color.path()); // "settings.theme.color"
console.log(color.value()); // e.g. blueNavigate using path strings
For deeply nested paths, use the at(path) method with a dot-separated path string:
1
2
3
4
5
6
7
// Navigate using a path string
const color = myObject.at('settings.theme.color');
console.log(color.path()); // "settings.theme.color"
// Equivalent to chained get() calls
const color = myObject.get('settings').get('theme').get('color');
console.log(color.path()); // "settings.theme.color"When using at(), dots (.) are treated as path separators. To include a literal dot in a key name, escape it with a backslash (\.):
1
2
3
4
5
6
7
// Key contains a dot
const apiEndpoint = myObject.get('config').get('api.endpoint');
console.log(apiEndpoint.path()); // "config.api\.endpoint"
// Use the escaped path with at()
const apiEndpoint = myObject.at('config.api\\.endpoint');
console.log(apiEndpoint.path()); // "config.api\.endpoint"The path() method returns the string representation of the current path, which can be used with the REST API.
Access data
Use the get(key) method to navigate to a nested path within a LiveMap. The returned PathObject represents whatever exists at that path. What you can do with that PathObject depends on whether the path resolves to a primitive value, a LiveCounter, or a nested LiveMap.
Read values
When an entry contains a primitive value or LiveCounter, use the value() method to get the current value:
1
2
3
4
5
6
7
// Get the value of an entry containing a primitive
const username = myObject.get('username');
console.log(username.value()); // e.g. "alice"
// Get the value of the LiveCounter stored in 'visits'
const visits = myObject.get('visits');
console.log(visits.value()); // e.g. 5If the entry doesn't exist, or the entry does not contain a primitive or a LiveCounter instance, value() returns undefined:
1
2
3
4
5
6
7
8
9
// Get the value of an entry that doesn't exist
const missing = myObject.get('nonexistent');
console.log(missing.value()); // undefined
// Set an entry that contains a nested LiveMap
await myObject.set('settings', LiveMap.create({ theme: 'dark' }));
// Calling value() on a LiveMap returns undefined
console.log(myObject.get('settings').value()); // undefined - it's a LiveMap, not a primitive or LiveCounterObtain object instances
To get the specific Instance at a path, use the instance() method:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Get the LiveCounter instance stored in '
const visits = myObject.get('visits').instance();
// Work with the specific LiveCounter instance
console.log(visits?.id);
await visits?.increment(1);
// Get the LiveMap instance stored in 'settings'
const settings = myObject.get('settings').instance();
// Work with the specific LiveMap instance
console.log(settings?.id);
await settings?.set('theme', 'dark');If the entry at they path does not exist, or the entry does not contain a LiveMap or LiveCounter object, instance() returns undefined:
1
2
3
// The 'username' entry contains a primitive, not an object
const username = myObject.get('username').instance();
console.log(username); // undefinedSee the Instance documentation for more details on working with object instances.
Enumerate collections
For paths that resolve to a LiveMap, you can iterate over the entries, keys, and values:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const settings = myObject.get('settings');
// Iterate over key-value pairs.
// Each key is a string, and the value is a PathObject for the entry.
for (const [key, value] of settings.entries()) {
console.log(`${key}:`, value.value());
}
// Iterate over keys only
for (const key of settings.keys()) {
console.log('Key:', key);
}
// Iterate over values
for (const value of settings.values()) {
console.log('Value:', value.value());
}Collection methods return empty iterators if the path doesn't resolve to a LiveMap:
1
2
3
4
5
const visits = myObject.get('visits'); // This is a LiveCounter, not a LiveMap
for (const [key, value] of visits.entries()) {
// This loop doesn't execute - entries() returns empty iterator
}Get the size of a collection
Use the size() method to get the number of entries in a LiveMap:
1
2
3
4
const settings = myObject.get('settings');
// Get the number of entries
console.log(settings.size());The size() method returns undefined if the path doesn't resolve to a LiveMap:
1
2
const visits = myObject.get('visits'); // This is a LiveCounter, not a LiveMap
console.log(visits.size()); // undefinedGet a compact object
The compact() method returns a JavaScript object representation of the data at a path:
1
2
3
4
5
6
7
8
9
10
const settings = myObject.get('settings');
const compact = settings.compact();
console.log(compact);
// {
// theme: { color: 'dark', fontSize: 14 },
// notifications: true
// }
const visits = myObject.get('visits');
console.log(visits.compact()); // Just the number, e.g., 42Binary data in the channel object (such as ArrayBuffer/Uint8Array in browser environments, or Buffer in Node.js) is preserved as typed arrays in the returned object.
If the channel object contains cyclic references, these are preserved in the returned data structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Imagine the channel object has this structure:
// root (LiveMap)
// ├─ user (LiveMap)
// │ └─ profile -> references root (creates cycle)
// └─ name: "Alice"
// When you call compact(), cyclic references are preserved as actual JS references
const root = myObject.compact();
console.log(root.user.profile === root); // true - actual cyclic reference
// You can navigate the cycle
console.log(root.user.profile.user.profile.name); // "Alice"
// This will throw an error because cycles can't be serialized
JSON.stringify(compact); // ❌ TypeError: cyclic object valueIt is possible for the value returned from compact() to contain cyclic references, so it is not safe to serialize this value with JSON.stringify().
Use the compactJson() method to obtain a value that can be safely passed to JSON.stringify():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Example: Using the same cyclic structure from before
// root (LiveMap)
// ├─ user (LiveMap)
// │ └─ profile -> references root (creates cycle)
// └─ name: "Alice"
// compactJson() breaks cycles, making it safe to serialize
const compactJson = myObject.compactJson();
console.log(compactJson);
// This can be safely serialized as the
// cycle is broken via an objectId reference
console.log(JSON.stringify(compactJson));
// {
// "user": {
// "profile": { "objectId": "root" }
// },
// "name": "Alice"
// }Binary data in the channel object are serialized as base64-encoded strings by compactJson():
1
2
3
4
5
6
7
// Store binary data in the channel object
const binaryData = new TextEncoder().encode("world");
await myObject.set('hello', binaryData);
// compactJson() converts binary data to base64 strings
const compactJson = myObject.compactJson();
console.log(compactJson.hello); // "d29ybGQ=" (base64 encoded)Update data
PathObject provides mutation methods that operate on the object at the resolved path. The specific methods available depend on the type of object at that path:
- For paths that resolve to a
LiveMap, you can use methods likeset()andremove(). See the LiveMap documentation for details on these methods. - For paths that resolve to a
LiveCounter, you can use methods likeincrement()anddecrement(). See the LiveCounter documentation for details on these methods.
When you call a method on a PathObject, the path is resolved to a specific instance at the time the method is called, and the operation is performed on that instance.
1
2
3
4
5
6
7
8
9
// Update a LiveMap
const settings = myObject.get('settings');
await settings.set('theme', 'dark');
await settings.remove('oldSetting');
// Update a LiveCounter
const visits = myObject.get('visits');
await visits.increment(5);
await visits.decrement(2);Batch multiple updates
The batch(callback) method groups multiple mutations into a single message. The ctx parameter is resolved to the specific object instance at the path where batch() is called:
1
2
3
4
5
6
const settings = myObject.get('settings');
await settings.batch((ctx) => {
ctx.set('theme', 'dark');
ctx.get('preferences').set('language', 'en');
ctx.get('volume').increment(5);
});See the Batch operations documentation for more details on batching.
Subscribe to changes
Use the subscribe() method to be notified when object data is updated:
1
2
3
4
5
6
7
8
const visits = myObject.get('visits');
const { unsubscribe } = visits.subscribe(() => {
console.log('Visits updated');
});
// Later, stop listening to changes
unsubscribe();Alternatively, use the subscribeIterator() method for an async iterator syntax:
1
2
3
4
5
6
7
8
9
const visits = myObject.get('visits');
for await (const _ of visits.subscribeIterator()) {
console.log('Visits updated');
if (someCondition) {
break; // Unsubscribes
}
}PathObject subscriptions observe a location rather than a specific object instance. If the object at the specified path is replaced, the subscription automatically continues to observe the new instance:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const visits = myObject.get('visits');
// Subscribe to the 'visits' path
visits.subscribe(() => {
console.log('Visits updated');
});
// This triggers the subscription
await visits.increment(5);
// Someone replaces the LiveCounter instance at 'visits'
await myObject.set('visits', LiveCounter.create(100));
// This triggers the subscription, which now observes the new LiveCounter
await visits.increment(1);You can subscribe to any path, including those containing primitive values:
1
2
3
4
5
6
7
const theme = myObject.get("settings").get('theme');
theme.subscribe(() => {
console.log('Theme updated:', theme.value());
});
await myObject.get("settings").set("theme", "dark");Determine what changed
The subscription receives an argument with information about the update:
- The
objectfield contains aPathObjectrepresenting the location of the object instance that was updated. - The
messagefield contains theObjectMessagewhich details the operation that caused the change, including information about the client that performed the operation and the specific changes made.
1
2
3
4
5
6
7
const visits = myObject.get('visits');
visits.subscribe(({ object, message }) => {
console.log('New value:', object.value());
console.log('Updated by:', message?.clientId);
console.log('Operation:', message?.operation.action);
});When subscribed to a LiveCounter, the object passed to the subscription is always a PathObject for the same path you subscribed to, since there can be no further nesting:
1
2
3
4
5
6
7
const visits = myObject.get('visits');
visits.subscribe(({ object }) => {
console.log(object.path()); // always "visits"
});
await visits.increment(1);When subscribed to a LiveMap, the object passed to the subscription is a PathObject to the location of the LiveMap, LiveCounter, or primitive value that was updated:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Subscribe to a LiveCounter stored in 'visits'
const visits = myObject.get('visits');
visits.subscribe(({ object, message }) => {
console.log('path:', object.path(), 'amount:', message?.operation?.counterOp?.amount);
});
await visits.increment(5);
// path: visits amount: 5
// Subscribe to a LiveMap stored in 'settings'
const settings = myObject.get('settings');
settings.subscribe(({ object, message }) => {
console.log('path:', object.path(), 'key:', message?.operation?.mapOp?.key);
});
await settings.set('theme', 'dark');
// path: settings key: theme
await settings.get('preferences').set('language', 'en');
// path: settings.preferences key: language
// Subscribe to the 'theme' key in the LiveMap stored in 'settings'
const settings = myObject.get('settings');
const theme = settings.get('theme');
theme.subscribe(({ object, message }) => {
console.log('path:', object.path(), 'key:', message?.operation?.mapOp?.key);
});
await settings.set('theme', 'dark');
// path: settings.theme key: themeSince the path of the object is dynamic, read from a known PathObject inside the subscription to access the latest values:
1
2
3
4
5
6
7
8
9
const settings = myObject.get('settings');
settings.subscribe(() => {
console.log("Theme:", settings.get("theme"));
console.log("Preferences:", settings.get("preferences").compactJson());
});
await settings.set('theme', 'dark');
await settings.get('preferences').set('language', 'en');Control subscription depth
By default, subscriptions observe changes at all nested levels. Use the depth option to limit how deep the subscription listens:
1
2
3
4
5
6
7
8
const settings = myObject.get('settings');
// Only observe direct changes to the settings LiveMap
// Changes to any nested objects are ignored
settings.subscribe(({ object }) => {
console.log('Settings updated');
console.log('Changed path:', object.path()); // Always "settings"
}, { depth: 1 });The depth option also works with async iterators:
1
2
3
4
for await (const { object } of settings.subscribeIterator({ depth: 1 })) {
console.log('Settings LiveMap updated');
console.log('Changed path:', object.path()); // Always "settings"
}