@justscale/observable

Proxy-based observable system with dirty tracking for TypeScript.
Installation
npm install @justscale/observable zod
Quick Start
import { z } from "zod";
import { createModel, getModelInternals, watch } from "@justscale/observable";
const schema = z.object({
user: z.object({
name: z.string().default(""),
score: z.number().default(0),
}),
});
const model = createModel(schema, { user: { name: "Alice" } });
watch(model, (paths) => console.log("Changed:", paths));
model.user.score = 100;
const internals = getModelInternals(model);
internals.getDirtyPaths();
internals.markClean();
Requirements
| Node.js | 14.6+ |
| Chrome | 84+ |
| Firefox | 79+ |
| Safari | 14.1+ |
| Edge | 84+ |
Uses WeakRef for parent tracking. structuredClone is used when available (Node 17+) with automatic fallback.
Features
- Dirty tracking - Know exactly which paths changed
- Deep nesting - Track changes at any depth with full parent paths
- Shared references - Same object in multiple locations tracks all paths
- Watch API - Callback or async generator (
for await) for change notifications
- Built-in support - Map, Set, Date, TypedArray, DataView all work
- Zod integration - Schema validation with full type inference
API
Models (with Zod schema)
import { z } from "zod";
import { createModel, getModelInternals } from "@justscale/observable";
const schema = z.object({
tags: z.array(z.string()).default([]),
});
const model = createModel(schema, {});
const internals = getModelInternals(model);
model.tags.push("active");
internals.getDirtyPaths();
internals.isDirty();
internals.markClean();
Observables (without schema)
import { createObservable, getObservableInternals } from "@justscale/observable";
const obs = createObservable({ count: 0, items: [] });
const internals = getObservableInternals(obs);
obs.count++;
obs.items.push("item");
internals.getDirtyPaths();
Watch API
import { watch } from "@justscale/observable";
const handle = watch(model, (paths) => {
console.log("Changed:", paths);
});
handle.unsubscribe();
const watcher1 = watch(model);
for await (const paths of watcher1) {
console.log("Changed:", paths);
if (shouldStop) watcher1.unsubscribe();
}
const watcher2 = watch(model);
const { value, done } = await watcher2.next();
if (!done) {
console.log("Changed:", value);
}
watcher2.unsubscribe();
Shared References
Two models can share the same data. Changes through either model mark both as dirty with their respective paths:
import { z } from "zod";
import { createModel, getModelInternals, createObservable } from "@justscale/observable";
const sharedProfile = createObservable({ name: "Alice", score: 100 });
const schema1 = z.object({ user: z.any() });
const schema2 = z.object({ player: z.any() });
const model1 = createModel(schema1, { user: sharedProfile });
const model2 = createModel(schema2, { player: sharedProfile });
model1.user.score = 200;
getModelInternals(model1).getDirtyPaths();
getModelInternals(model2).getDirtyPaths();
model1.user.score;
model2.player.score;
getModelInternals(model1).markClean();
getModelInternals(model1).isDirty();
getModelInternals(model2).isDirty();
Built-in Objects
const obs = createObservable({
cache: new Map(),
tags: new Set(),
updated: new Date(),
});
obs.cache.set("key", "value");
obs.tags.add("new");
obs.updated.setFullYear(2025);
Dirty Path Reference
obj.foo = 1 | ["foo"] |
obj.a.b.c = 1 | ["a.b.c", "a.b", "a"] |
arr.push(x) | ["arr.0", "arr"] |
arr.pop() | ["arr.N", "arr.length", "arr"] |
arr[0] = x | ["arr.0", "arr"] |
map.set(k, v) | ["map"] |
set.add(x) | ["set"] |
date.setFullYear(x) | ["date"] |
Limitations
Private Fields
Classes with private fields (#field) throw TypeError - methods are bound to the proxy which breaks private field access.
Frozen/Sealed Objects
Cannot observe frozen or sealed objects - we need to attach a symbol property for internals.
Built-in Granularity
Built-in mutations (Map, Set, Date) track the container, not individual keys - we can't intercept internal slot mutations granularly.
License
MIT