# PathObject
`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](https://ably.com/docs/liveobjects/concepts/objects#channel-object), which serves as the entry point for navigation.
```javascript
// 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](https://ably.com/docs/channels/states?lang=javascript#attach) to the channel if not already attached. The returned promise resolves when the channel object data has been [synchronized](https://ably.com/docs/liveobjects/concepts/synchronization) 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:
```javascript
// 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:
```javascript
import { type PathObject, LiveCounter, LiveMap } from 'ably/liveobjects';
type MyObject = {
visits: LiveCounter;
settings: LiveMap<{
theme: string;
notifications: boolean;
}>;
};
const myObject = await channel.object.get();
// TypeScript knows 'visits' is a LiveCounter
const visits: PathObject = myObject.get('visits');
await visits.increment(1); // Type-safe
// TypeScript knows the shape of 'settings'
const theme: PathObject = myObject.get('settings').get('theme');
const value = theme.value(); // Returns string | undefined
```
See the [Typing documentation](https://ably.com/docs/liveobjects/typing) 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:
```javascript
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. blue
```
### Navigate using path strings
For deeply nested paths, use the `at(path)` method with a dot-separated path string:
```javascript
// 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 (`\.`):
```javascript
// 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](https://ably.com/docs/liveobjects/rest-api-usage).
## 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:
```javascript
// 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. 5
```
If the entry doesn't exist, or the entry does not contain a primitive or a `LiveCounter` instance, `value()` returns `undefined`:
```javascript
// 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 LiveCounter
```
### Obtain object instances
To get the specific [`Instance`](https://ably.com/docs/liveobjects/concepts/instance) at a path, use the `instance()` method:
```javascript
// 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`:
```javascript
// The 'username' entry contains a primitive, not an object
const username = myObject.get('username').instance();
console.log(username); // undefined
```
See the [Instance documentation](https://ably.com/docs/liveobjects/concepts/instance) 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:
```javascript
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`:
```javascript
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`:
```javascript
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`:
```javascript
const visits = myObject.get('visits'); // This is a LiveCounter, not a LiveMap
console.log(visits.size()); // undefined
```
### Get a compact object
The `compact()` method returns a JavaScript object representation of the data at a path:
```javascript
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., 42
```
Binary 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:
```javascript
// 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 value
```
It 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()`:
```javascript
// 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()`:
```javascript
// 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 like `set()` and `remove()`. See the [LiveMap documentation](https://ably.com/docs/liveobjects/map) for details on these methods.
- For paths that resolve to a `LiveCounter`, you can use methods like `increment()` and `decrement()`. See the [LiveCounter documentation](https://ably.com/docs/liveobjects/counter) 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.
```javascript
// 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:
```javascript
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](https://ably.com/docs/liveobjects/batch) for more details on batching.
## Subscribe to changes
Use the `subscribe()` method to be notified when object data is updated:
```javascript
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:
```javascript
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:
```javascript
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:
```javascript
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 `object` field contains a `PathObject` representing the location of the object instance that was updated.
- The `message` field contains the [`ObjectMessage`](https://ably.com/docs/liveobjects/concepts/operations#properties) which details the operation that caused the change, including information about the client that performed the operation and the specific changes made.
```javascript
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:
```javascript
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:
```javascript
// 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: theme
```
Since the path of the `object` is dynamic, read from a known `PathObject` inside the subscription to access the latest values:
```javascript
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:
```javascript
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:
```javascript
for await (const { object } of settings.subscribeIterator({ depth: 1 })) {
console.log('Settings LiveMap updated');
console.log('Changed path:', object.path()); // Always "settings"
}
```