Skip to content

Commit 40e258d

Browse files
authored
Merge pull request #1922 from ably/DTP-963/liveobjects-customer-typings
[DTP-963] Add support for customer provided typings for LiveObjects
2 parents b09f7bf + 3938b63 commit 40e258d

File tree

7 files changed

+129
-26
lines changed

7 files changed

+129
-26
lines changed

ably.d.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2039,25 +2039,76 @@ export declare interface LiveObjects {
20392039
/**
20402040
* Retrieves the root {@link LiveMap} object for state on a channel.
20412041
*
2042+
* A type parameter can be provided to describe the structure of the LiveObjects state on the channel. By default, it uses types from the globally defined `LiveObjectsTypes` interface.
2043+
*
2044+
* You can specify custom types for LiveObjects by defining a global `LiveObjectsTypes` interface with a `root` property that conforms to {@link LiveMapType}.
2045+
*
2046+
* Example:
2047+
*
2048+
* ```typescript
2049+
* import { LiveCounter } from 'ably';
2050+
*
2051+
* type MyRoot = {
2052+
* myTypedKey: LiveCounter;
2053+
* };
2054+
*
2055+
* declare global {
2056+
* export interface LiveObjectsTypes {
2057+
* root: MyRoot;
2058+
* }
2059+
* }
2060+
* ```
2061+
*
20422062
* @returns A promise which, upon success, will be fulfilled with a {@link LiveMap} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error.
20432063
*/
2044-
getRoot(): Promise<LiveMap>;
2064+
getRoot<T extends LiveMapType = DefaultRoot>(): Promise<LiveMap<T>>;
20452065
}
20462066

2067+
declare global {
2068+
/**
2069+
* A globally defined interface that allows users to define custom types for LiveObjects.
2070+
*/
2071+
export interface LiveObjectsTypes {
2072+
[key: string]: unknown;
2073+
}
2074+
}
2075+
2076+
/**
2077+
* Represents the type of data stored in a {@link LiveMap}.
2078+
* It maps string keys to scalar values ({@link StateValue}), or other LiveObjects.
2079+
*/
2080+
export type LiveMapType = { [key: string]: StateValue | LiveMap<LiveMapType> | LiveCounter | undefined };
2081+
2082+
/**
2083+
* The default type for the `root` object in the LiveObjects, based on the globally defined {@link LiveObjectsTypes} interface.
2084+
*
2085+
* - If no custom types are provided in `LiveObjectsTypes`, defaults to an untyped root map representation using the {@link LiveMapType} interface.
2086+
* - If a `root` type exists in `LiveObjectsTypes` and conforms to the {@link LiveMapType} interface, it is used as the type for the `root` object.
2087+
* - If the provided `root` type does not match {@link LiveMapType}, a type error message is returned.
2088+
*/
2089+
export type DefaultRoot =
2090+
// we need a way to know when no types were provided by the user.
2091+
// we expect a "root" property to be set on LiveObjectsTypes interface, e.g. it won't be "unknown" anymore
2092+
unknown extends LiveObjectsTypes['root']
2093+
? LiveMapType // no custom types provided; use the default untyped map representation for the root
2094+
: LiveObjectsTypes['root'] extends LiveMapType
2095+
? LiveObjectsTypes['root'] // "root" property exists, and it is of an expected type, we can use this interface for the root object in LiveObjects.
2096+
: `Provided type definition for the "root" object in LiveObjectsTypes is not of an expected LiveMapType`;
2097+
20472098
/**
20482099
* The `LiveMap` class represents a key/value map data structure, similar to a JavaScript Map, where all changes are synchronized across clients in realtime.
20492100
* Conflict-free resolution for updates follows Last Write Wins (LWW) semantics, meaning that if two clients update the same key in the map, the update with the most recent timestamp wins.
20502101
*
20512102
* Keys must be strings. Values can be another Live Object, or a primitive type, such as a string, number, boolean, or binary data (see {@link StateValue}).
20522103
*/
2053-
export declare interface LiveMap extends LiveObject<LiveMapUpdate> {
2104+
export declare interface LiveMap<T extends LiveMapType> extends LiveObject<LiveMapUpdate> {
20542105
/**
20552106
* Returns the value associated with a given key. Returns `undefined` if the key doesn't exist in a map.
20562107
*
20572108
* @param key - The key to retrieve the value for.
20582109
* @returns A {@link LiveObject}, a primitive type (string, number, boolean, or binary data) or `undefined` if the key doesn't exist in a map.
20592110
*/
2060-
get(key: string): LiveObject | StateValue | undefined;
2111+
get<TKey extends keyof T & string>(key: TKey): T[TKey];
20612112

20622113
/**
20632114
* Returns the number of key/value pairs in the map.

src/plugins/liveobjects/livemap.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import deepEqual from 'deep-equal';
22

3+
import type * as API from '../../../ably';
34
import { LiveObject, LiveObjectData, LiveObjectUpdate, LiveObjectUpdateNoop } from './liveobject';
45
import { LiveObjects } from './liveobjects';
56
import {
@@ -45,7 +46,7 @@ export interface LiveMapUpdate extends LiveObjectUpdate {
4546
update: { [keyName: string]: 'updated' | 'removed' };
4647
}
4748

48-
export class LiveMap extends LiveObject<LiveMapData, LiveMapUpdate> {
49+
export class LiveMap<T extends API.LiveMapType> extends LiveObject<LiveMapData, LiveMapUpdate> {
4950
constructor(
5051
liveObjects: LiveObjects,
5152
private _semantics: MapSemantics,
@@ -59,8 +60,8 @@ export class LiveMap extends LiveObject<LiveMapData, LiveMapUpdate> {
5960
*
6061
* @internal
6162
*/
62-
static zeroValue(liveobjects: LiveObjects, objectId: string): LiveMap {
63-
return new LiveMap(liveobjects, MapSemantics.LWW, objectId);
63+
static zeroValue<T extends API.LiveMapType>(liveobjects: LiveObjects, objectId: string): LiveMap<T> {
64+
return new LiveMap<T>(liveobjects, MapSemantics.LWW, objectId);
6465
}
6566

6667
/**
@@ -69,8 +70,8 @@ export class LiveMap extends LiveObject<LiveMapData, LiveMapUpdate> {
6970
*
7071
* @internal
7172
*/
72-
static fromStateObject(liveobjects: LiveObjects, stateObject: StateObject): LiveMap {
73-
const obj = new LiveMap(liveobjects, stateObject.map?.semantics!, stateObject.objectId);
73+
static fromStateObject<T extends API.LiveMapType>(liveobjects: LiveObjects, stateObject: StateObject): LiveMap<T> {
74+
const obj = new LiveMap<T>(liveobjects, stateObject.map?.semantics!, stateObject.objectId);
7475
obj.overrideWithStateObject(stateObject);
7576
return obj;
7677
}
@@ -82,24 +83,25 @@ export class LiveMap extends LiveObject<LiveMapData, LiveMapUpdate> {
8283
* then you will get a reference to that Live Object if it exists in the local pool, or undefined otherwise.
8384
* If the value is not an objectId, then you will get that value.
8485
*/
85-
get(key: string): LiveObject | StateValue | undefined {
86+
// force the key to be of type string as we only allow strings as key in a map
87+
get<TKey extends keyof T & string>(key: TKey): T[TKey] {
8688
const element = this._dataRef.data.get(key);
8789

8890
if (element === undefined) {
89-
return undefined;
91+
return undefined as T[TKey];
9092
}
9193

9294
if (element.tombstone === true) {
93-
return undefined;
95+
return undefined as T[TKey];
9496
}
9597

9698
// data exists for non-tombstoned elements
9799
const data = element.data!;
98100

99101
if ('value' in data) {
100-
return data.value;
102+
return data.value as T[TKey];
101103
} else {
102-
return this._liveObjects.getPool().get(data.objectId);
104+
return this._liveObjects.getPool().get(data.objectId) as T[TKey];
103105
}
104106
}
105107

src/plugins/liveobjects/liveobjects.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,18 @@ export class LiveObjects {
3636
this._bufferedStateOperations = [];
3737
}
3838

39-
async getRoot(): Promise<LiveMap> {
39+
/**
40+
* When called without a type variable, we return a default root type which is based on globally defined LiveObjects interface.
41+
* A user can provide an explicit type for the getRoot method to explicitly set the LiveObjects type structure on this particular channel.
42+
* This is useful when working with LiveObjects on multiple channels with different underlying data.
43+
*/
44+
async getRoot<T extends API.LiveMapType = API.DefaultRoot>(): Promise<LiveMap<T>> {
4045
// SYNC is currently in progress, wait for SYNC sequence to finish
4146
if (this._syncInProgress) {
4247
await this._eventEmitter.once(LiveObjectsEvents.SyncCompleted);
4348
}
4449

45-
return this._liveObjectsPool.get(ROOT_OBJECT_ID) as LiveMap;
50+
return this._liveObjectsPool.get(ROOT_OBJECT_ID) as LiveMap<T>;
4651
}
4752

4853
/**
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { LiveCounter, LiveMap } from 'ably';
2+
3+
type CustomRoot = {
4+
numberKey: number;
5+
stringKey: string;
6+
booleanKey: boolean;
7+
couldBeUndefined?: string;
8+
mapKey?: LiveMap<{
9+
foo: 'bar';
10+
nestedMap?: LiveMap<{
11+
baz: 'qux';
12+
}>;
13+
}>;
14+
counterKey?: LiveCounter;
15+
};
16+
17+
declare global {
18+
export interface LiveObjectsTypes {
19+
root: CustomRoot;
20+
}
21+
}
Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import * as Ably from 'ably';
22
import LiveObjects from 'ably/liveobjects';
3+
import { CustomRoot } from './ably.config';
34
import { createSandboxAblyAPIKey } from './sandbox';
45

6+
type ExplicitRootType = {
7+
someOtherKey: string;
8+
};
9+
510
globalThis.testAblyPackage = async function () {
611
const key = await createSandboxAblyAPIKey({ featureFlags: ['enableChannelState'] });
712

@@ -11,12 +16,27 @@ globalThis.testAblyPackage = async function () {
1116
// check liveObjects can be accessed
1217
const liveObjects = channel.liveObjects;
1318
await channel.attach();
14-
// root should be a LiveMap object
15-
const root: Ably.LiveMap = await liveObjects.getRoot();
19+
// expect root to be a LiveMap instance with LiveObjects types defined via the global LiveObjectsTypes interface
20+
// also checks that we can refer to the LiveObjects types exported from 'ably' by referencing a LiveMap interface
21+
const root: Ably.LiveMap<CustomRoot> = await liveObjects.getRoot();
22+
23+
// check root has expected LiveMap TypeScript type methods
24+
const size: number = root.size();
1625

17-
// check root is recognized as LiveMap TypeScript type
18-
root.get('someKey');
19-
root.size();
26+
// check custom user provided typings via LiveObjectsTypes are working:
27+
// keys on a root:
28+
const aNumber: number = root.get('numberKey');
29+
const aString: string = root.get('stringKey');
30+
const aBoolean: boolean = root.get('booleanKey');
31+
const couldBeUndefined: string | undefined = root.get('couldBeUndefined');
32+
// live objects on a root:
33+
const counter: Ably.LiveCounter | undefined = root.get('counterKey');
34+
const map: LiveObjectsTypes['root']['mapKey'] = root.get('mapKey');
35+
// check string literal types works
36+
// need to use nullish coalescing as we didn't actually create any data on the root,
37+
// so the next calls would fail. we only need to check that TypeScript types work
38+
const foo: 'bar' = map?.get('foo')!;
39+
const baz: 'qux' = map?.get('nestedMap')?.get('baz')!;
2040

2141
// check LiveMap subscription callback has correct TypeScript types
2242
const { unsubscribe } = root.subscribe(({ update }) => {
@@ -31,13 +51,15 @@ globalThis.testAblyPackage = async function () {
3151
});
3252
unsubscribe();
3353

34-
// check LiveCounter types also behave as expected
35-
const counter = root.get('randomKey') as Ably.LiveCounter | undefined;
36-
// use nullish coalescing as we didn't actually create a counter object on the root,
37-
// so the next calls would fail. we only need to check that TypeScript types work
38-
const value: number = counter?.value();
54+
// check LiveCounter type also behaves as expected
55+
// same deal with nullish coalescing
56+
const value: number = counter?.value()!;
3957
const counterSubscribeResponse = counter?.subscribe(({ update }) => {
4058
const shouldBeANumber: number = update.inc;
4159
});
4260
counterSubscribeResponse?.unsubscribe();
61+
62+
// check can provide custom types for the getRoot method, ignoring global LiveObjectsTypes interface
63+
const explicitRoot: Ably.LiveMap<ExplicitRootType> = await liveObjects.getRoot<ExplicitRootType>();
64+
const someOtherKey: string = explicitRoot.get('someOtherKey');
4365
};

test/package/browser/template/src/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"include": ["**/*.ts", "**/*.tsx"],
33
"compilerOptions": {
4+
"strictNullChecks": true,
45
"resolveJsonModule": true,
56
"esModuleInterop": true,
67
"module": "esnext",

typedoc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@
2020
"TypeAlias",
2121
"Variable",
2222
"Namespace"
23-
]
23+
],
24+
"intentionallyNotExported": ["__global.LiveObjectsTypes"]
2425
}

0 commit comments

Comments
 (0)