Advanced

Custom data types

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.

Why custom data types?

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.

interface ITextDocumentIndex {
  pageIndex: number;
  lineIndex: number;
}

class TextDocument {
  private readonly _pages = new Map<number, Map<number, string>>();

  constructor(pages?: string[][]) {
    pages?.forEach((page, pageIndex) => {
      const pageMap = new Map<number, string>();
      this._pages.set(pageIndex, pageMap);
      page.forEach((line, lineIndex) => pageMap.set(lineIndex, line));
    });
  }

  public getLine(index: ITextDocumentIndex): string | undefined {
    return this._pages.get(index.pageIndex)?.get(index.lineIndex);
  }

  public setLine(index: ITextDocumentIndex, text: string): void {
    let page = this._pages.get(index.pageIndex);

The four contracts

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 { type IProxyRegistry } from '@rs-x/core';

// Wraps a TextDocument in a Proxy.
// Intercepts setLine() calls to emit IPropertyChange events.
class TextDocumentObserver extends AbstractObserver<TextDocument> {
  constructor(
    textDocument: TextDocument,
    private readonly _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 = new Proxy(textDocument, this);
    this._proxyRegister.register(textDocument, this.target);
  }

  protected override disposeInternal(): 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.
class TextDocumentIndexObserver extends AbstractObserver<
  TextDocument,
  string,
  ITextDocumentIndex
> {
  private readonly _changeSubscription: Subscription;

  constructor(
    owner: IDisposableOwner,
    private readonly _observer: TextDocumentObserver,
    index: ITextDocumentIndex,
  ) {
    super(
      owner,
      _observer.target,
      _observer.target.getLine(index),
      new ReplaySubject(),

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';
import type { IIndexValueAccessor } from '@rs-x/core';

@Injectable()
export class TextDocumentIndexAccessor
  implements IIndexValueAccessor<TextDocument, ITextDocumentIndex>
{
  public readonly priority = 200;

  // Tell rs-x which objects this accessor handles.
  public applies(object: unknown, _index: ITextDocumentIndex): boolean {
    return object instanceof TextDocument;
  }

  public hasValue(doc: TextDocument, index: ITextDocumentIndex): boolean {
    return doc.getLine(index) !== undefined;
  }

  public getValue(doc: TextDocument, index: ITextDocumentIndex): string | undefined {
    return doc.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()
class TextDocumentObserverManager extends KeyedInstanceFactory<
  TextDocument,
  TextDocument,
  TextDocumentObserver
> {
  constructor(
    @Inject(RsXCoreInjectionTokens.IProxyRegistry)
    private readonly _proxyRegister: IProxyRegistry,
  ) {
    super();
  }

  public override getId(doc: TextDocument): TextDocument {
    return doc; // identity by reference
  }

  protected override createId(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,
  type IObjectObserverProxyPairFactory,
  type IObjectObserverProxyPairManager,
  type IObserverProxyPair,
  type IProxyTarget,
  type IPropertyInfo,
} from '@rs-x/state-manager';

// ① Object-level factory — wraps a whole TextDocument in a proxy.
//    Used when stateManager.watchState(model, 'myBook', { ... }) is called.
@Injectable()
export class TextDocumentObserverProxyPairFactory
  implements IObjectObserverProxyPairFactory
{
  public readonly priority = 100;

  constructor(
    @Inject(MyInjectTokens.TextDocumentObserverManager)
    private readonly _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.
import {
  ContainerModule,
  defaultIndexValueAccessorList,
  overrideMultiInjectServices,
  RsXCoreInjectionTokens,
} from '@rs-x/core';
import {
  defaultObjectObserverProxyPairFactoryList,
  defaultPropertyObserverProxyPairFactoryList,
  RsXStateManagerInjectionTokens,
  RsXStateManagerModule,
} from '@rs-x/state-manager';
import { InjectionContainer } from '@rs-x/core';

const MyInjectTokens = {
  TextDocumentObserverManager:     Symbol('TextDocumentObserverManager'),
  TextDocumentIndexObserverManager: Symbol('TextDocumentIndexObserverManager'),
  TextDocumentIndexAccessor:        Symbol('TextDocumentIndexAccessor'),
  TextDocumentObserverProxyPairFactory:
    Symbol('TextDocumentObserverProxyPairFactory'),
  TextDocumentIndexObserverProxyPairFactory:
    Symbol('TextDocumentIndexObserverProxyPairFactory'),

Usage — watch entire document

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,
  type IStateManager,
} from '@rs-x/state-manager';

const stateManager: IStateManager = InjectionContainer.get(
  RsXStateManagerInjectionTokens.IStateManager,
);

const model = {
  myBook: new TextDocument([
    ['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, type IProxyRegistry } from '@rs-x/core';
import {
  RsXStateManagerInjectionTokens,
  type IStateManager,
  type IStateChange,
} from '@rs-x/state-manager';

const stateManager: IStateManager = InjectionContainer.get(
  RsXStateManagerInjectionTokens.IStateManager,
);
const proxyRegistry: IProxyRegistry = InjectionContainer.get(
  RsXCoreInjectionTokens.IProxyRegistry,
);

const doc = new TextDocument([['Hello world', 'Second line', 'Third line']]);

// Watch only one cell — the cell doesn't need to exist yet.
const targetIndex = { pageIndex: 0, lineIndex: 2 };
stateManager.watchState(doc, targetIndex);

stateManager.changed.subscribe((change: IStateChange) => {
  const idx = change.index as ITextDocumentIndex;

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,
  type IIdentifierOwnerResolver,
  registerMultiInjectServices,
  RsXExpressionParserInjectionTokens,
  rsx, RsXExpressionParserModule,
} from '@rs-x/expression-parser';

// ── 5th contract: tell the expression parser that TextDocument
//    owns ITextDocumentIndex keys ─────────────────────────────
@Injectable()
class TextDocumentIndexOwnerResolver implements IIdentifierOwnerResolver {
  resolve(index: unknown, context: unknown): object | null {
    if (!(context instanceof TextDocument)) return null;
    const idx = index as ITextDocumentIndex;
    if (typeof idx?.pageIndex !== 'number') return null;
    if (typeof idx?.lineIndex !== 'number') return null;
    return context; // doc is the owner of its cell indices
  }
}

// Register in MyModule (alongside the other overrideMultiInjectServices calls):