Skip to content

Instantly share code, notes, and snippets.

@Aldlevine
Last active September 5, 2023 02:35
Show Gist options
  • Save Aldlevine/6d8e6ef8674ac221acabcf85898a6199 to your computer and use it in GitHub Desktop.
Save Aldlevine/6d8e6ef8674ac221acabcf85898a6199 to your computer and use it in GitHub Desktop.
Typescript Observable
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_}`);
});
/**
* 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);
});
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment