Last active
September 5, 2023 02:35
-
-
Save Aldlevine/6d8e6ef8674ac221acabcf85898a6199 to your computer and use it in GitHub Desktop.
Typescript Observable
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Observable } from "./observable.js"; | |
const num = new Observable(0); | |
const num2 = new Observable(0); | |
const fract = new Observable(2); | |
const step = fract | |
.map(p => Math.pow(10, -(p ?? 0))); | |
const result = Observable | |
.map(num, num2, (n1: number, n2: number) => n1 * n2) | |
.toFixed(fract); | |
const numInput = <HTMLInputElement>document.getElementById("num"); | |
num.bind(numInput, "value", "input"); | |
step.bind(numInput, "step"); | |
const num2Input = <HTMLInputElement>document.getElementById("num2"); | |
num2.bind(num2Input, "value", "input"); | |
step.bind(num2Input, "step"); | |
const fractInput = <HTMLInputElement>document.getElementById("fract"); | |
fract.bind(fractInput, "value", "input"); | |
const output = <HTMLDivElement>document.getElementById("output"); | |
result.bind(output, "innerHTML"); | |
Observable.watch(num, num2, result, (num_, num2_, fixed_) => { | |
console.log(`num = ${num_}, num2 = ${num2_}, fixed = ${fixed_}`); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Any function that transforms an object of type {@link From} to type {@link To} | |
* | |
* @template From | |
* @template To | |
* | |
* @export | |
*/ | |
export type TransformFn<From, To> = (v: From) => To; | |
/** | |
* Tuple of primitive types supported by {@link Observable} | |
* | |
* @export | |
*/ | |
export type Primitives = [void, boolean, number, string]; | |
/** | |
* Union of primitive types supported by {@link Observable} | |
* | |
* @export | |
*/ | |
export type Primitive = Primitives[number]; | |
/** | |
* Converts a possible literal primitive to a {@link Primitive} | |
* | |
* @template T | |
*/ | |
type PrimitiveOf<T extends Primitive> = | |
T extends boolean ? boolean : | |
T extends number ? number : | |
T extends string ? string : | |
never; | |
/** | |
* Converts a tuple of possible literal primitives to a tuple of {@link Primitive}s | |
* | |
* @template T | |
*/ | |
type PrimitiveTuple<T extends Primitive[]> = { | |
[P in keyof T]: PrimitiveOf<T[P]>; | |
} | |
/** | |
* Identifies the object type associated with a given {@link Primitive} | |
* | |
* @template T | |
*/ | |
type ObjectOf<T> = | |
T extends boolean ? Boolean : | |
T extends number ? Number : | |
T extends string ? String : | |
T extends object ? T : | |
never; | |
/** | |
* Identifies the constructor type associated with a given {@link Primitive} | |
* | |
* @template T | |
*/ | |
type ConstructorOf<T> = | |
T extends boolean | Boolean ? BooleanConstructor : | |
T extends number | Number ? NumberConstructor : | |
T extends string | String ? StringConstructor : | |
T extends object ? T : | |
never; | |
/** | |
* Gets the constructor function for a given {@link Primitive} | |
* | |
* @template T | |
* @param {T} v the primitive | |
* @returns {ConstructorOf<T>} | |
*/ | |
function ctorOf<T extends Primitive>(v: T): ConstructorOf<T> { | |
if (typeof v === "boolean") { | |
return Boolean as ConstructorOf<T>; | |
} | |
if (typeof v === "number") { | |
return Number as ConstructorOf<T>; | |
} | |
if (typeof v === "string") { | |
return String as ConstructorOf<T>; | |
} | |
throw new Error(`cannot find constructor of ${v}`); | |
} | |
/** | |
* Either an {@link Observable}<{@link T}> or {@link T} | |
* @date 9/4/2023 - 12:40:06 PM | |
* | |
* @export | |
* @template T | |
*/ | |
export type ObservableOr<T extends Primitive> = Observable<T> | T; | |
/** | |
* Converts {@link T} to an {@link Observable}<{@link T}> if it's a {@link Primitive} | |
* | |
* @export | |
* @template T | |
*/ | |
export type ObservableOrAny<T> = T extends Primitive ? ObservableOr<T> : T; | |
/** | |
* Convert a tuple of {@link T} to a tuple of {@link Observable}<{@link T}> | |
* | |
* @export | |
* @template T | |
*/ | |
export type ObservableTuple<T extends Primitive[]> = { | |
[P in keyof T]: Observable<T[P]>; | |
}; | |
/** | |
* Convert a tuple of {@link T} to a tuple of {@link ObservableOr}<{@link T}> | |
* | |
* @export | |
* @template T | |
*/ | |
export type ObservableOrTuple<T extends Primitive[]> = { | |
[P in keyof T]: ObservableOr<T[P]>; | |
} | |
/** | |
* Convert a tuple of {@link T} to a tuple of {@link ObservableOrAny}<{@link T}> | |
* | |
* @export | |
* @template T | |
*/ | |
export type ObservableOrAnyTuple<T> = { | |
[P in keyof T]: ObservableOrAny<T[P]>; | |
} | |
/** | |
* Maps a function with Primitive arguments / return to | |
* one with equivalent Observable arguments / return. | |
* | |
* @template T | |
*/ | |
type ObservableFunction<T extends Function> = | |
T extends (...args: infer A) => (infer R extends Primitive) | |
? (...args: { [P in keyof A]: ObservableOrAny<A[P]> }) => Observable<R> | |
: never; | |
/** | |
* The callable component of the {@link Observable} constructor. | |
*/ | |
type ObservableCtor = { | |
<T extends Primitive>(v: T): Observable<T>; | |
new <T extends Primitive>(v: T): Observable<T>; | |
} | |
/** | |
* The static components of the {@link Observable} constructor | |
*/ | |
class ObservableStatic { | |
/** | |
* Type guard for {@link Observable}s, optionally of a given type. | |
* | |
* @static | |
* @template T | |
* @param {*} o the object to check | |
* @param {?ConstructorOf<T>} [type] the {@link Primitive} object class type to check for | |
* @returns {o is Observable<T>} | |
*/ | |
static isObservable<T extends Primitive>(o: any): o is Observable<any>; | |
static isObservable<T extends boolean>(o: any, type: BooleanConstructor): o is Observable<T>; | |
static isObservable<T extends number>(o: any, type: NumberConstructor): o is Observable<T>; | |
static isObservable<T extends string>(o: any, type: StringConstructor): o is Observable<T>; | |
static isObservable<T extends Primitive>(o: any, type?: ConstructorOf<T>): o is Observable<T> { | |
if (!(o instanceof _Observable)) { | |
return false; | |
} | |
if (type === Boolean) { | |
return typeof o.get() === "boolean"; | |
} | |
if (type === Number) { | |
return typeof o.get() === "number"; | |
} | |
if (type === String) { | |
return typeof o.get() === "string"; | |
} | |
return !type || o.get() instanceof type; | |
} | |
/** | |
* Safely gets a value from an {@link Observable} or {@link Primitive} | |
* | |
* @static | |
* @template T | |
* @param {ObservableOr<T>} o an {@link Observable} or {@link Primitive} | |
* @returns {T} the value | |
*/ | |
static getValue<T extends Primitive>(o: ObservableOr<T>): T { | |
if (ObservableStatic.isObservable(o)) { | |
return o.get(); | |
} | |
return o; | |
} | |
/** | |
* Same as {@link getValue} but applies to a tuple / array. tuple / array may be heterogeneous. | |
* | |
* @static | |
* @template T | |
* @param {...ObservableOrTuple<T>} os the {@link Observable}s and or {@link Primitive}s to get the values of. | |
* @returns {T} a tuple of values | |
*/ | |
static getAllValues<T extends any[]>(...os: ObservableOrTuple<T>): T { | |
return os.map(ObservableStatic.getValue) as T; | |
} | |
/** | |
* Watches each of the observables and calls the callback with their values | |
* as arguments when any one changes. | |
* | |
* @static | |
* @template T | |
* @param {...ObservableTuple<T>} [...os] the observables to watch | |
* @param {(...args: PrimitiveTuple<T>) => void} fn the callback | |
* @example ```ts | |
* const num = new Observable(0); | |
* const num2 = new Observable(0); | |
* Observable.watch(num, num2, (n: number, n2: number) => { | |
* console.log(`num = ${n}, num2 = ${n2}`); | |
* }); | |
* num.set(10); // >> num = 10, num2 = 0 | |
* num2.set(20); // >> num = 10, num2 = 20 | |
* ``` | |
*/ | |
static watch<T extends any[]>( | |
...args: [ | |
...ObservableTuple<T>, | |
(...args: PrimitiveTuple<T>) => void | |
] | |
): void { | |
const fn = <(...args: T) => void>args.pop(); | |
const args_ = <ObservableTuple<T>>args; | |
const handler = () => { | |
fn.call(null, ...Observable.getAllValues(...args_) as T); | |
} | |
for (let o of args_) { | |
o.addEventListener("change", handler); | |
} | |
} | |
/** | |
* Similar to {@link watch} but produces an {@link Observable}<{@link R}> with | |
* the result of the callback. | |
* | |
* @static | |
* @template T | |
* @template R | |
* @param {...ObservableTuple<T>} [...os] the observables to watch | |
* @param {(...args: PrimitiveTuple<T>) => R} fn the callback | |
* @returns {Observable<R>} | |
* @example ```ts | |
* const num = new Observable(0); | |
* const num2 = new Observable(0); | |
* const sum = Observable.map(num, num2, (n, n2) => n + n2); | |
* sum.watch(v => console.log(`sum = ${v}`)) | |
* num.set(10); // >> sum = 10 | |
* num2.set(20); // >> sum = 30 | |
* ``` | |
*/ | |
static map< | |
T extends any[], | |
R extends Primitive | |
>( | |
...args: [ | |
...ObservableTuple<T>, | |
(...args: PrimitiveTuple<T>) => R | |
] | |
): Observable<R> { | |
const fn = <(...args: T) => R>args.pop(); | |
const args_ = <ObservableTuple<T>>args; | |
const value = fn.call(null, ...Observable.getAllValues(...args_) as T); | |
const result = new Observable(value); | |
ObservableStatic.watch(...args_, (...values) => { | |
const value = fn.call(null, ...values as T); | |
result.set(value); | |
}); | |
return result; | |
} | |
} | |
/** | |
* Union of {@link Observable} event names | |
* | |
* @export | |
*/ | |
export type ObservableEventName = "change"; | |
/** | |
* Subclass of {@link Event} for {@link Observable}s | |
* | |
* @export | |
* @template T | |
* @extends {Event} | |
*/ | |
export class ObservableEvent<T> extends Event { | |
value?: T; | |
constructor(type: ObservableEventName, value: T) { | |
super(type); | |
this.value = value; | |
} | |
}; | |
/** | |
* An {@link Observable} which provides a reactive | |
* interface to an underlying data type. Proxies | |
* the methods associtated with the underlying data | |
* type such that they return {@link Observable}s | |
* with equivalent value, and allow both {@link Primitive}s, | |
* and {@link Observable}s as arguments. | |
* | |
* @export | |
* @template T | |
* @template O | |
*/ | |
export type Observable<T extends Primitive, O extends ObjectOf<T> = ObjectOf<T>> = _Observable<T> & { | |
[P in keyof O]: | |
O[P] extends Function | |
? ObservableFunction<O[P]> | |
: O[P] extends Primitive | |
? Observable<O[P]> | |
: never; | |
} | |
/** | |
* @param {T} v the initial value | |
* @returns {Observable<T>} the observable | |
*/ | |
export const Observable = function <T extends Primitive>(v: T): Observable<T> { | |
return _Observable.create(v); | |
} as (ObservableCtor & typeof ObservableStatic); | |
/** apply {@link ObservableStatic} to {@link Observable} */ | |
for (let key of Object.getOwnPropertyNames(ObservableStatic)) { | |
Object.defineProperty(Observable, key, { | |
value: ObservableStatic[key as keyof ObservableStatic] | |
}); | |
} | |
Object.defineProperty(Observable, Symbol.hasInstance, { | |
value: (o: any) => o instanceof _Observable | |
}); | |
/** | |
* A {@link ProxyHandler} that either forwards directly to {@link _Observable} | |
* OR to a wrapper around the underlying {@link Primitive}'s method / accessor | |
* which returns an {@link Observable} which reevaluates whenever the parent | |
* {@link Observable}(s) change. | |
* | |
* @implements {ProxyHandler<_Observable<T>>} | |
*/ | |
class _ObservableProxyHandler implements ProxyHandler<_Observable<any>> { | |
static get<T extends Primitive>( | |
target: _Observable<T>, | |
p: string | symbol, | |
receiver: any | |
): any { | |
const value = target.get(); | |
if (value !== null && value !== undefined) { | |
const attr = value[p as keyof T]; | |
if (typeof attr === "function") { | |
return (...args: any[]) => { | |
const observables: Observable<any>[] = [target, ...args.filter(Observable.isObservable)]; | |
const value = attr.apply(target.get(), Observable.getAllValues(...args)) | |
const result = Observable(value); | |
Observable.watch(...observables, (..._: any[]) => { | |
result.set(attr.call(target.get(), ...Observable.getAllValues(...args))); | |
}); | |
return result; | |
} | |
} | |
else if (attr !== undefined) { | |
return target.map(v => v[p as keyof T] as Primitive); | |
} | |
} | |
const attr = target[p as keyof _Observable<T>]; | |
if (typeof attr === "function") { | |
return attr.bind(target); | |
} | |
return target[p as keyof _Observable<T>]; | |
} | |
static set<T extends Primitive>( | |
target: _Observable<T>, | |
p: string | symbol, | |
value: T, | |
receiver: any | |
): boolean { | |
const desc = Object.getOwnPropertyDescriptor(target, p); | |
if (desc?.set) { | |
desc.set.call(target, value); | |
} | |
else { | |
target[p as keyof _Observable<T>] = value as any; | |
} | |
return true; | |
} | |
} | |
/** | |
* The actual {@link Observable} implementation. | |
* Because we are using {@link Proxy}, we need to | |
* separate the public type / constructor ({@link Observable}) | |
* from the internal implementation ({@link _Observable}) | |
* | |
* @template T | |
* @extends {EventTarget} | |
*/ | |
interface _Observable<T extends Primitive> extends EventTarget { | |
addEventListener( | |
type: ObservableEventName, | |
callback: ((evt: ObservableEvent<T>) => void), | |
options?: boolean | AddEventListenerOptions | undefined, | |
): void; | |
removeEventListener( | |
type: ObservableEventName, | |
callback: ((evt: ObservableEvent<T>) => void), | |
options?: boolean | AddEventListenerOptions | undefined | |
): void; | |
} | |
class _Observable<T extends Primitive> extends EventTarget { | |
#value: T; | |
/** | |
* Creates an instance of {@link _Observable}. | |
* Private because {@link create} is required for correct typing with {@link Observable}. | |
* | |
* @constructor | |
* @private | |
* @param {T} value | |
*/ | |
private constructor(value: T) { | |
super(); | |
this.#value = value; | |
this.coerce = (v) => ctorOf<T>(this.get())(v) as T; | |
} | |
/** | |
* Create an instance of {@link Observable}. | |
* | |
* @static | |
* @template T | |
* @param {T} value | |
* @returns {Observable<T>} | |
*/ | |
static create<T extends Primitive>(value: T): Observable<T> { | |
return new Proxy(new _Observable(value), _ObservableProxyHandler) as Observable<T>; | |
} | |
/** | |
* Gets the {@link Observable}'s value. | |
* | |
* @returns {T} | |
*/ | |
get(): T { | |
return this.#value; | |
} | |
/** | |
* Sets the {@link Observable}'s value. | |
* Emits a change event if {@link coerce}({@link v}) !== {@link get}() | |
* | |
* @param {*} v | |
*/ | |
set(v: any) { | |
v = this.coerce(v); | |
if (this.#value !== v) { | |
this.#value = v; | |
this.dispatchEvent(new ObservableEvent("change", v)); | |
} | |
} | |
/** | |
* A {@link TransformFn<any, T>} that coerces any value to {@link T}. | |
* Defaults to the constructor function of {@link T}. | |
* | |
* @type {TransformFn<any, T>} | |
*/ | |
coerce: TransformFn<any, T>; | |
/** @alias addEventListener */ | |
on = this.addEventListener; | |
/** @alias removeEventListener */ | |
off = this.removeEventListener; | |
/** | |
* Watches the observable and calls the callback with its value | |
* as the argument when changed. | |
* | |
* @param {TransformFn<T, any>} fn the callback | |
* @see {@link Observable.watch} | |
*/ | |
watch(fn: TransformFn<T, any>): void { | |
this.on("change", (evt) => { | |
fn(evt.value!); | |
}); | |
} | |
/** | |
* Similar to {@link watch} but produces an {@link Observable}<{@link R}> with | |
* the result of the callback. | |
* @date 9/4/2023 - 9:53:02 PM | |
* | |
* @template R | |
* @param {TransformFn<T, R>} fn the callback | |
* @returns {Observable<R>} | |
* @see {@link Observable.map} | |
*/ | |
map<R extends Primitive>(fn: TransformFn<T, R>): Observable<R> { | |
const result = _Observable.create(fn(this.get())); | |
this.watch(v => { | |
result.set(fn(v)); | |
}); | |
return result; | |
} | |
/** | |
* Binds an {@link Observable} to an attribute of an object. If the object is an | |
* {@link EventTarget}, an event name can be provided to act as a 2 way data-bind. | |
* | |
* @template O | |
* @template K | |
* @param {O} obj the object to bind to (must be {@link EventTarget} of {@link event} is provided). | |
* @param {K} attr the attribute key to bind to | |
* @param {string} [event] the event on {@link obj} to listen for, will check {@link obj}[{@link attr}] for changes when fired. | |
* @param {TransformFn<T, O[K]>} [coerce] a {@link TransformFn<T, O[K]>} to coerce values to the necessary type. | |
*/ | |
bind<O extends object, K extends keyof O>(obj: O, attr: K): void; | |
bind<O extends EventTarget, K extends keyof O>(obj: O, attr: K, event: string): void; | |
bind<O extends object, K extends keyof O>(obj: O, attr: K, coerce: TransformFn<T, O[K]>): void; | |
bind<O extends EventTarget, K extends keyof O>(obj: O, attr: K, event: string, coerce: TransformFn<T, O[K]>): void; | |
bind< | |
O extends object | EventTarget, | |
K extends keyof O | |
>( | |
obj: O, | |
attr: K, | |
...args: ( | |
[] | | |
[string] | | |
[TransformFn<T, O[K]>] | | |
[string, TransformFn<T, O[K]>] | |
) | |
): void { | |
let event: string | null = null; | |
let coerce: (TransformFn<T, O[K]>) | null = null; | |
for (let arg of args) { | |
if (typeof arg === "function") { | |
coerce = arg; | |
continue; | |
} | |
if (typeof arg === "string") { | |
event = arg; | |
} | |
} | |
obj[attr] = <O[K]>(coerce ? coerce(this.get()) : this.get()); | |
if (event !== null && obj instanceof EventTarget) { | |
obj.addEventListener(event, () => { | |
this.set(obj[attr]); | |
}); | |
} | |
this.watch(v => { | |
obj[attr] = <O[K]>(coerce ? coerce(v) : v); | |
}); | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
... |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment