Skip to content

Instantly share code, notes, and snippets.

@ralphschuler
Created November 1, 2023 22:38
Show Gist options
  • Save ralphschuler/2d7efff77342de5d7432ccc03032f1b2 to your computer and use it in GitHub Desktop.
Save ralphschuler/2d7efff77342de5d7432ccc03032f1b2 to your computer and use it in GitHub Desktop.
Random Typescript ObjectObserver
// Custom error classes
class ObjectAlreadyObservedError extends Error {
constructor(identifier: Identifier) {
super(`Object with identifier ${identifier} is already being observed.`);
Object.setPrototypeOf(this, ObjectAlreadyObservedError.prototype);
}
}
class ObjectNotObservedError extends Error {
constructor(identifier: Identifier) {
super(`No object observed with identifier: ${identifier}`);
Object.setPrototypeOf(this, ObjectNotObservedError.prototype);
}
}
type Identifier = string;
type PropertyList<T> = keyof T[];
type ObjectChangedCallback<T> = (identifier: Identifier, updatedObject: T, eventType: EventType, propertyName?: keyof T) => void;
type ObjectWithCallbacks<T> = { proxy: T, original: T, callbacks?: ObjectChangedCallback<T>[], observedProperties?: PropertyList<T> };
enum EventType {
PropertyChanged = 'propertyChanged',
BatchUpdateCompleted = 'batchUpdateCompleted'
}
class ObjectObserver extends Map<Identifier, ObjectWithCallbacks<unknown>> {
private readonly pendingBatchUpdates = new Set<Identifier>();
private notifyObservers<T>(identifier: Identifier, updatedObject: T, eventType: EventType, propertyName?: keyof T): void {
if (this.pendingBatchUpdates.has(identifier)) return;
this.get(identifier)?.callbacks?.forEach(callback => callback(identifier, updatedObject, eventType, propertyName));
}
private beginBatchUpdate(identifier: Identifier): void {
this.pendingBatchUpdates.add(identifier);
}
private endBatchUpdate(identifier: Identifier): void {
this.pendingBatchUpdates.delete(identifier);
if (!this.get(identifier)?.original) return;
this.notifyObservers(identifier, this.get(identifier)!.original, EventType.BatchUpdateCompleted);
}
private createSetterLogic<T>(obj: T, prop: keyof T, value: T[keyof T], identifier: Identifier, propertiesToWatch?: PropertyList<T>): boolean {
if (!propertiesToWatch?.includes(prop) && propertiesToWatch) return true;
Reflect.set(obj, prop, value);
this.notifyObservers(identifier, obj, EventType.PropertyChanged, prop);
if (typeof value !== 'object' || Array.isArray(value)) return true;
Reflect.set(obj, prop, this.createDeepProxy(value, identifier, propertiesToWatch));
return true;
}
private createDeepProxy<T>(
target: T,
identifier: Identifier,
propertiesToWatch?: PropertyList<T>
): T {
return new Proxy(target, {
set: (obj, prop, value) => this.createSetterLogic(obj, prop, value, identifier, propertiesToWatch),
});
}
observe<T>(
target: T,
identifier: Identifier,
triggerInitialCallback = false,
propertiesToWatch?: PropertyList<T>
): T {
if (this.has(identifier)) throw new ObjectAlreadyObservedError(identifier);
const proxy = this.createDeepProxy(target, identifier, propertiesToWatch);
this.set(identifier, { proxy, original: target });
if (triggerInitialCallback) {
this.notifyObservers(identifier, target, EventType.PropertyChanged);
}
return proxy;
}
addCallback<T>(identifier: Identifier, callback: ObjectChangedCallback<T>): void {
const objectInfo = this.get(identifier);
if (!objectInfo) throw new ObjectNotObservedError(identifier);
objectInfo.callbacks = objectInfo.callbacks ?? [];
objectInfo.callbacks.push(callback);
}
removeObservation(identifier: Identifier, property?: keyof any): void {
const objectInfo = this.get(identifier);
if (!objectInfo) return;
objectInfo.observedProperties = objectInfo.observedProperties ?? [];
objectInfo.observedProperties = objectInfo.observedProperties.filter(prop => prop !== property);
if (!property) {
this.delete(identifier);
}
}
batchUpdate<T>(identifier: Identifier, updates: Partial<T>): void {
const objectInfo = this.get(identifier);
if (!objectInfo) throw new ObjectNotObservedError(identifier);
this.beginBatchUpdate(identifier);
objectInfo.original = { ...objectInfo.original, ...updates };
this.endBatchUpdate(identifier);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment