Teach rs-x to observe any object — custom classes, domain models, or third-party structures — by implementing four small contracts: an observer, a proxy factory, an index accessor, and a DI module that wires them together.
rs-x ships with built-in observation support for plain objects, Arrays, Maps, Sets, Dates, Promises, and Observables. When your domain model contains a type that does not fit any of those categories — for example a paginated document, a binary buffer, or a third-party class whose mutations go through a custom API — you can extend the system without modifying the core library.
The extension point is a set of injectable services registered via Inversify's ContainerModule and the overrideMultiInjectServices helper. Once registered, rs-x automatically uses your implementation wherever it encounters an instance of your type.
Running example: TextDocument
The demo throughout this page uses a TextDocument — a multi-page text structure addressed by a compound index { pageIndex, lineIndex }. A standard plain-object or array accessor cannot handle this compound index, so we implement every contract ourselves.
Supporting a new data type requires implementing the following pieces, each of which is described in detail below:
Observer — extends AbstractObserver and wraps the custom object in a Proxy that intercepts mutations and emits IPropertyChange events.
Index observer — extends AbstractObserver and watches a single logical slot (cell, bucket, key) inside the object. Filters change events by index identity.
Index value accessor — implements IIndexValueAccessor and teaches rs-x how to read and write individual slots without going through the proxy.
Observer proxy pair factories — tell the state manager how to create (and share) observers when watchState is called at the object level or at the index level.
Step 1 — Object observer
The object observer wraps the entire TextDocument in a Proxy. When setLine is intercepted, it calls the real method and then calls emitChange with an IPropertyChange that carries the compound index. All downstream index observers listen to this single change stream and filter by their own index.
The observer also registers the proxy with IProxyRegistry so that rs-x can detect, at any point, whether an object reference is already a proxy — preventing double-wrapping.
import{AbstractObserver}from'@rs-x/state-manager';import{typeIProxyRegistry}from'@rs-x/core';// Wraps a TextDocument in a Proxy.// Intercepts setLine() calls to emit IPropertyChange events.classTextDocumentObserverextendsAbstractObserver<TextDocument>{constructor(textDocument:TextDocument,privatereadonly_proxyRegister:IProxyRegistry,owner?:IDisposableOwner,){super(owner,Type.cast(undefined),textDocument);// Create the proxy and register it so rs-x can identify// proxied instances throughout the pipeline.this.target=newProxy(textDocument,this);this._proxyRegister.register(textDocument,this.target);}protectedoverridedisposeInternal():void{this._proxyRegister.unregister(this.value);}
Step 2 — Index observer
The index observer watches a single cell inside a document. It subscribes to the parent TextDocumentObserver's change stream and re-emits only the events whose pageIndex and lineIndex match. This is the same pattern as the built-in ArrayIndexObserver, which filters array-change events by slot index.
The index observer receives the initial cell value in its constructor so that the expression has a value immediately on bind without waiting for the first mutation.
import{AbstractObserver}from'@rs-x/state-manager';import{ReplaySubject,Subscription}from'rxjs';// Watches a single cell (pageIndex, lineIndex) inside a TextDocument.// Only emits when the matching cell is updated.classTextDocumentIndexObserverextendsAbstractObserver<TextDocument,string,ITextDocumentIndex>{privatereadonly_changeSubscription:Subscription;constructor(owner:IDisposableOwner,privatereadonly_observer:TextDocumentObserver,index:ITextDocumentIndex,){super(owner,_observer.target,_observer.target.getLine(index),newReplaySubject(),
Step 3 — Index value accessor
IIndexValueAccessor is the bridge between the rs-x expression pipeline and a custom indexed collection. It answers: "Given an object and an index, how do I read and write a value?" The applies(object, index) guard ensures the accessor is only invoked for TextDocument instances with valid ITextDocumentIndex values.
getResolvedValue exists for async types such as Promises, where getValue returns the Promise wrapper while getResolvedValue returns the unwrapped result. For synchronous custom types both methods return the same thing.
import{Injectable}from'@rs-x/core';importtype{IIndexValueAccessor}from'@rs-x/core';@Injectable()exportclassTextDocumentIndexAccessorimplementsIIndexValueAccessor<TextDocument,ITextDocumentIndex>{publicreadonlypriority=200;// Tell rs-x which objects this accessor handles.publicapplies(object:unknown,_index:ITextDocumentIndex):boolean{returnobjectinstanceofTextDocument;}publichasValue(doc:TextDocument,index:ITextDocumentIndex):boolean{returndoc.getLine(index)!==undefined;}publicgetValue(doc:TextDocument,index:ITextDocumentIndex):string|undefined{returndoc.getLine(index);}
Step 4 — Shared observer management
KeyedInstanceFactory is a reference-counted cache keyed by identity. If two bindings both watch the same TextDocument instance, they share a single TextDocumentObserver. When the last binding is disposed the observer is released automatically.
The TextDocumentIndexObserverManager follows the same pattern but is keyed by both document identity and cell index, using a Cantor pairing function to produce a unique numeric ID from { pageIndex, lineIndex }.
import{KeyedInstanceFactory,Injectable,Inject}from'@rs-x/core';// Ensures the same TextDocument always produces the same observer instance.// If two bindings both watch the same document, they share one observer.@Injectable()classTextDocumentObserverManagerextendsKeyedInstanceFactory<TextDocument,TextDocument,TextDocumentObserver>{constructor(@Inject(RsXCoreInjectionTokens.IProxyRegistry)privatereadonly_proxyRegister:IProxyRegistry,){super();}publicoverridegetId(doc:TextDocument):TextDocument{returndoc;// identity by reference}protectedoverridecreateId(doc:TextDocument):TextDocument{
Step 5 — Observer proxy pair factories
The state manager discovers how to wrap an object by asking its list of registered factories (in priority order) which one applies. Two factories are needed:
Object-level factory (TextDocumentObserverProxyPairFactory) — invoked when the whole document is passed to watchState. Returns a proxy / observer pair for the document.
Index-level factory (TextDocumentIndexObserverProxyPairFactory) — invoked when a compound index is passed to watchState. Extends IndexObserverProxyPairFactory, which handles the boilerplate of combining the object observer with a per-slot index observer.
import{Injectable,Inject}from'@rs-x/core';import{IndexObserverProxyPairFactory,typeIObjectObserverProxyPairFactory,typeIObjectObserverProxyPairManager,typeIObserverProxyPair,typeIProxyTarget,typeIPropertyInfo,}from'@rs-x/state-manager';// ① Object-level factory — wraps a whole TextDocument in a proxy.// Used when stateManager.watchState(model, 'myBook', { ... }) is called.@Injectable()exportclassTextDocumentObserverProxyPairFactoryimplementsIObjectObserverProxyPairFactory{publicreadonlypriority=100;constructor(@Inject(MyInjectTokens.TextDocumentObserverManager)privatereadonly_manager:TextDocumentObserverManager,){}
Step 6 — DI registration
All custom services are wired together in a ContainerModule. The key helper is overrideMultiInjectServices, which replaces the default multi-inject list with a new list that prepends your implementation. Always spread the default list at the end so that built-in support for Array, Map, Set, Date, etc. continues to work for all other types in the same application.
Three lists need to be extended:
IIndexValueAccessorList — register the custom index accessor.
IObjectObserverProxyPairFactoryList — register the object-level factory.
IPropertyObserverProxyPairFactoryList — register the index-level factory.
Once the module is loaded, pass a TextDocument to watchState just like any other value. The watchIndexRecursiveRule enables recursive observation so that every cell mutation triggers the subscriber.
import{InjectionContainer}from'@rs-x/core';import{RsXStateManagerInjectionTokens,watchIndexRecursiveRule,typeIStateManager,}from'@rs-x/state-manager';conststateManager:IStateManager=InjectionContainer.get(RsXStateManagerInjectionTokens.IStateManager,);constmodel={myBook:newTextDocument([['Once upon a time','Bla bla'],['Page two line one','They lived happily ever after.'],]),};// watchIndexRecursiveRule turns on recursive observation for all cells.stateManager.watchState(model,'myBook',{indexWatchRule:watchIndexRecursiveRule,});
Usage — watch a single cell
Pass the compound index directly to watchState to watch a specific cell. The cell does not need to exist at watch time — as soon as setLine writes to that index the change is emitted. Mutations must go through the proxy obtained from IProxyRegistry so that the observer intercepts them.
import{InjectionContainer,RsXCoreInjectionTokens,typeIProxyRegistry}from'@rs-x/core';import{RsXStateManagerInjectionTokens,typeIStateManager,typeIStateChange,}from'@rs-x/state-manager';conststateManager:IStateManager=InjectionContainer.get(RsXStateManagerInjectionTokens.IStateManager,);constproxyRegistry:IProxyRegistry=InjectionContainer.get(RsXCoreInjectionTokens.IProxyRegistry,);constdoc=newTextDocument([['Hello world','Second line','Third line']]);// Watch only one cell — the cell doesn't need to exist yet.consttargetIndex={pageIndex:0,lineIndex:2};stateManager.watchState(doc,targetIndex);stateManager.changed.subscribe((change:IStateChange)=>{constidx=change.indexasITextDocumentIndex;
Usage — bind in expressions
The custom index accessor is also picked up by the expression parser. An expression like rsx('doc[cell]')(model) delegates to TextDocumentIndexAccessor to read the cell value. The expression re-evaluates reactively when either the cell address (model.cell) or the document itself (model.doc) changes.
For proxy mutations to be detected — when proxy.setLine(...) is called — you also need a IIdentifierOwnerResolver that tells the expression parser that a TextDocument owns its ITextDocumentIndex keys. Without it the slot subscription cannot be set up on the right object.
Always store the index as a model field rather than an inline object literal — doc[{ pageIndex: 1, lineIndex: 0 }] creates a new object on every evaluation, breaking the accessor's identity checks.
Note: this example requires MyModule to be loaded in the DI container before binding. It cannot be demonstrated in the online playground because the playground runs in an isolated environment where custom DI modules cannot be registered.
import{Injectable,InjectionContainer,typeIIdentifierOwnerResolver,registerMultiInjectServices,RsXExpressionParserInjectionTokens,rsx,RsXExpressionParserModule,}from'@rs-x/expression-parser';// ── 5th contract: tell the expression parser that TextDocument// owns ITextDocumentIndex keys ─────────────────────────────@Injectable()classTextDocumentIndexOwnerResolverimplementsIIdentifierOwnerResolver{resolve(index:unknown,context:unknown):object|null{if(!(contextinstanceofTextDocument))returnnull;constidx=indexasITextDocumentIndex;if(typeofidx?.pageIndex!=='number')returnnull;if(typeofidx?.lineIndex!=='number')returnnull;returncontext;// doc is the owner of its cell indices}}// Register in MyModule (alongside the other overrideMultiInjectServices calls):