cd /news/developer-tools/i-abused-js-to-abuse-yjs · home › topics › developer-tools › article
[ARTICLE · art-143167] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

I abused JS to abuse Yjs

A developer built Plexus, an open-source library that lets existing TypeScript class-based object models become collaborative and local-first by wrapping Yjs CRDTs behind MobX-style decorators, rather than rewriting the application around Yjs data structures. The project was motivated by a model spanning millions of lines of code that could not be rewritten, so the approach preserves class behavior by changing only the decorators used. The core package was written by hand, with AI assistance limited to tests, auxiliary packages, and documentation structuring.

by read14 min views3 publishedOct 1, 2026

Because I just wanted to keep my TypeScript classes.

I was not trying to build a CRDT framework. Honestly, I really, really wanted to avoid building one, and I guess you can see that in the API. I ended up creating Plexus (https://plexus.here.build/, https://github.com/here-build/plexus).

I had a fairly large TypeScript object model. It originally grew out of the open-source model by third party, and it already had a mental model I liked: classes, inheritance, object references, Maps, Sets, arrays, and MobX for reactivity.

Then I needed it to become collaborative and local-first.

The obvious answer was Yjs. The less obvious problem was that I really, really didn’t want to rewrite the application around Yjs data model; I wanted to keep using the same classes.

Disclaimer on AI usage inside Plexus:

Package core was fully written manually; tests and small amount of latter additions like extra methods on data structures were AI-assisted, but the core was 100% hand-crafted. Examples and some auxiliary packages like y-messageport were mostly AI-written and reviewed thoroughly afterwards.

Documentation was originally handwritten; AI only participated in structuring it and proofreading.

Yjs is a CRDT engine.

CRDT, explained very roughly: instead of keeping one authoritative copy of your state, it gives you shared data structures whose changes can be made independently on different peers and later merged without everybody having to serialize their writes through one server. Alice sets foo.bar = 1, Bob sets foo.baz = 2, both get {bar: 1, baz: 2}. Things get messy with arrays, but conceptually it’s working this way.

It gives you things like Y.Map, Y.Array, Y.Text, transactions, events, update encoding, and all the machinery needed to make replicated state actually converge.

That’s great. But if you use those structures directly, they very quickly become your application model.

Instead of this:

class Component {
  name = "";
  children: Component[] = [];
}

you start writing something more like

const component = new Y.Map();

component.set("name", "");

const children = new Y.Array();
component.set("children", children);

And now everywhere that touches your model needs to know that name lives in a Y.Map, that children is a Y.Array, how nested values are materialized, what an observer receives, when to transact, how references are represented, and so on. And yes, the API and behavior of them differ from native objects.

There is nothing wrong with that API; I just already had an application model, and I didn’t want another one.

The model I had was already built around classes and MobX decorators. It looked roughly like this:

class DataSourceDefinition implements HasLineage {
  @observable name: string;
  @observable label?: string;
  @observable description?: string;
  @observable category?: string;
  @observable type: "query" | "mutation" | CustomFunction | DataQueryFetch;
  @observable papp: PartialApplication;
  @observable extends?: DataSourceDefinition;
  @observable.shallow invalidates: DefinitionInvalidationTarget[] = [];
  @observable.shallow externalLinks: Record<string, string> = {};
}

The problem I had was that this data model was already used across millions of SLOC. I just could not afford rewriting it in full - I had to preserve the external behavior of my classes, so the only way to make it work was to change the decorator used, not the model.

To still be thinking in terms of Component, name, and hidden was more or less the design constraint that turned into Plexus.

Not “make Yjs easier to use” but “still keep not thinking about Yjs outside the connection layer.”

Unfortunately, “just keep writing classes” turns out to be a surprisingly demanding requirement.

The first problem I’ve encountered was the decorators.

There are two versions of standard working today - stage-2 and “modern one”. It has no proper single name - official ECMAScript docs mark it as stage 2.7, MobX internally calls them “2023 decorators”, TypeScript names them stage-3, while configuration flags name them “Not experimental”. I’ll refer to them as stage-3 as it’s the most widely used term nowadays, but you may encounter other ones too.

Stage-3 decorators are more “honest” decorators - they are unable to modify the external contract of the entity, only the behavior of the internals. Stage-2 allowed you to do basically anything you want within a class where the decorator was used.

Stage-3 is way more restricted than stage-2. In order for future compatibility, I’ve intentionally taken stage-3-only support to avoid the temptation of using the way more flexible stage-2.

To explain the effort of “polymorphic decorators”, look how MobX handles them.

A decorator on an auto-accessor can replace its getter and setter:

function syncing(target, context) {
  return {
    get() {
      // read replicated/reactive state
    },

    set(value) {
      // update replicated/reactive state
    },
  };
}

That means application code still gets to do this:

component.name
component.name = "Header"

While considerably fewer normal things can be done underneath. E.g. MobX reactivity detects reads and writes to trigger the notifications propagation this way. Same processing can be used for local mirror, undo preservation, and, well, networking and replication. Of course it looked like a 20-minute adventure.

Suppose I want this API:

class PlexusModel {
  constructor(init) {
    Object.assign(this, init);
  }
}

class Foo extends PlexusModel {
  @syncing
  accessor answer = 67;
}

new Foo({
  answer: 69,
});

Semantics are obvious. 67 is the schema default; 69 is an explicit constructor argument.

But that’s an application-level interpretation. JavaScript just has initialization order. 69 (nice) should win. But we’re in 2026, meaning 67 wins.

The problem here is that inside the parent class (PlexusModel) we cannot override what happens inside the child class (Foo), and the initializer of Foo happens after anything inside the parent constructor; no way we can dominate here.

This is where one of the stranger parts of both the old and current decorator proposal becomes extremely useful: accessor decorators get an init() hook. It gives us the ability to understand whether we’re dealing with the constructor defaults phase or actual usage of the class.

In simplified form, to solve that problem Plexus does something like:

return {
  get() {
    // ...
  },

  set(value) {
    // ...
  },

  init(defaultValue) {
    this.answer = this.initializationData.answer ?? defaultValue;
    return this.answer;
  },
};

But that’s not enough. Actually, things get worse. First, what if the user wants explicit undefined or null to dominate the default value? Second, what if there is a chain of defaults? And third - there’s pretty nasty nuance in modern JS.

I originally reached for decorators because I wanted less framework in my application code, and this required learning far too much about decorators.

Classes aren’t particularly useful if your abstraction only works until somebody writes extends. One of the weirdest issues is that one:

class Foo {
  name: string = "foo";
}

class FooBar extends Foo {
  name: "foo" | "bar";
}

new FooBar().name // -> undefined

On JS side, it turns into this:

class FooBar extends Foo {
  name;
}

// internally, it behaves this way:

class FooBar extends Foo {
  name = undefined;
}

When you name the variable (you don’t usually do it in JS, but you do in TS to narrow types down), you force it into existence, with undefined used as a default value. This looks like a type refinement in TypeScript, but unless you use declare keyword, it can emit a real JavaScript field — and a real JavaScript field initializes.

Since Yjs does not have native undefined type, the answer became possible - just say “undefined is impossible, null and others are actually overwriting.”

On top of that, I had another problem. I needed child-parent relations, meaning that it can be overridden. Consider:

class Parent extends PlexusModel {
  @syncing
  accessor value = "...";
}
class Child extends Parent {
  @syncing.child
  accessor value;
}

The semantics need to be changed, while the default value should be preserved for cases where it’s needed. In Plexus, a regular synced field and an owned child field mean different things. So the schema needs to follow the class hierarchy too.

Stage-3-style decorators expose metadata through context.metadata, which sounds convenient until inheritance enters the room.

Plexus ends up giving the metadata schema an actual prototype chain:

if (!Object.hasOwn(context.metadata, "schema")) {
  context.metadata.schema = {
    __proto__: context.metadata.schema ?? {},
  };
}

Yes, that is a prototype chain inside decorator metadata mirroring another prototype chain that exists because the application model itself uses inheritance.

At some point you stop asking whether you’re abusing JavaScript and start asking whether JavaScript seems to enjoy it.

There is another ugly case. JS (and TS) can emit property declarations in subclasses that shadow an accessor inherited from a parent. One example that backfired for me was that one:

class Component {
  @syncing accessor type: string;
}

class PageComponent extends Component {
  type = "page";
}

class ImageComponent extends Component {
  type: "image" | "inline-svg";
}

This is according to spec actually: if field type is used, it dominates previously annotated accessor field, making class to lose the expected behavior. It seems not that bad, but after I made that mistake myself twice, struggling for days searching for the problem, I understood that it should be countered. First I tried to detect it, but detecting and fixing that problem were too close; I ended up with a solution that just handles this problem seamlessly.

Inside the PlexusModel class, this humongous hack happens:

Object.defineProperties(
      this,
      Object.fromEntries(
        Object.keys(this.__schema__).map((key) => {
          let prototype = Object.getPrototypeOf(this);
          while (prototype && prototype !== prototype.__proto__) {
            if (Object.hasOwn(prototype, key)) {
              break;
            }
            prototype = prototype.__proto__;
          }
          invariant(
            prototype,
            `Plexus<${(this.constructor as PlexusConstructor).modelName}>: schema field "${key}" not found in prototype chain`,
          );
          return [
            key,
            // this helps us auto-correct user's mistakes when instead of accessor declaration of schema field
            // prop declaration is used - this only happens in children of synced elements, thus, we just need to override
            // "wrong" field with its actual behavior.
            // this also makes all of them enumerable of course. examples of "why it's needed" are in inheritance tests
            {
              ...Object.getOwnPropertyDescriptor(prototype, key),
              enumerable: true,
              configurable: true,
            } satisfies PropertyDescriptor,
          ] as const;
        }),
      ),
    );

So Plexus walks the prototype chain directly inside the constructor, finds the real property descriptor (from the closest parent - remember there’s override support), and installs it back onto the instance.

The library is effectively saying: “I know what you meant. Please stop helping.”

That sounds horrifying in isolation. But the alternative is making every user of the library understand the implementation detail that caused the problem - there’s no cheaper option to detect that problem and report it to the user. I choose to keep the demons inside.

This became a recurring pattern.

If Plexus asks application code to stop using normal JavaScript whenever something becomes replicated, then it isn’t really hiding the abstraction, but rather just giving the replication model nicer names.

For example, imagine this perfectly ordinary code:

class Project extends PlexusModel {
  @syncing.map
  accessor components = new Map<string, Component>();
}

What should this do?

project.components.set("header", header);
project.components.get("header");
project.components.has("header");
for (const [id, component] of project.components) {
  // ...
}

My answer was: exactly what it looks like. I didn't want something like this:

project.components.setReplicated(...)
// or
project.components.yMap.set(...)

or any other invented collection API that everybody has to memorize, or rely on autocomplete. Plexus collections try very hard to remain the OG JavaScript collections.

This is where things get properly stupid.

The Plexus Map implementation maintains its own backing structure, tracks reads for reactivity, translates writes into Yjs operations, reports MobX reactivity, handles references and ownership, and listens for remote changes.

But from application code, it should still be a Map.

The implementation eventually contains this:

Reflect.setPrototypeOf(self, Map.prototype);
Object.freeze(self);

This is an object implementing the Map surface, followed by: “Fine. You’re a Map now”. And that’s not even the worst one.

Plexus also supports Uint8Array values that can be mutated in place while still behaving like reactive replicated fields. A real Uint8Array is awkward to proxy because typed arrays have special indexed-property invariants. So Plexus does not proxy a real Uint8Array. Instead, it proxies this:

const target = {};

const self = new Proxy(target, {
  getPrototypeOf() {
    return Uint8Array.prototype;
  },
// ...
})

// so that
self instanceof Uint8Array // -> true

The proxy intercepts reads and writes, makes fresh byte arrays for mutation, syncs those through Yjs, blocks aliasing operations that would leak the underlying mutable buffer, and forwards safe methods to the actual bytes.

The target is {}. Because internally, Uint8Array, just like, e.g., Blob, is one of the “special internal types” that has its own semantics. You cannot just define the getter on a proxied Uint8Array; it will bypass and interact with the native class data structure instead of the proxy.

I think at that point the title of this post became unavoidable.

At some point in time, I encountered a huge problem. I was implementing all the methods that keep the semantics running - e.g., Array push, pop, map, sort, etc.- and I started encountering the weird bugs I did not expect. New ECMAScript spec versions were introducing cool new features I forgot to support here and there, so to solve the maintenance debt problem, I had to design the consistency validation.

If a future runtime adds a mutating method and Plexus doesn’t know about it, blindly forwarding it could bypass synchronization.

So the code classifies the native method surface:

const UINT8ARRAY_METHODS = {
  readonly: [
    "slice",
    "map",
    "find",
    // ...
  ],

  mutating: [
    "set",
    "fill",
    "sort",
    // ...
  ],

  banned: [
    "subarray",
  ],
} as const satisfies Record<string, ReadonlyArray<MethodsOf<Uint8Array>>>;

// and then has a type-level exhaustiveness check.

type MissingMethods = Exclude<
  MethodsOf<Uint8Array>,
  KnownMethods
>;

type MustBeNever =
  AssertNever<MissingMethods>;

So when the TypeScript standard library learns about a new Uint8Array method, Plexus can fail to compile until that method has been explicitly classified; and next guards check explicitly that each and every method was used in some way. The type system becomes a tripwire for changes in the JavaScript language, and this actually already caught the newer base64/hex methods.

Taken individually, a lot of this looks like unnecessary cleverness.

Why use decorator initialization hooks?

Why mirror inheritance through metadata prototypes?

Why repair descriptors?

Why make fake Maps?

Why impersonate a Uint8Array with a Proxy around {}?

Because every one of those hacks pays for deleting a concept from application code. The code using Plexus shouldn’t need to understand how a replicated scalar is stored. It shouldn’t need to know how a collection is encoded into Yjs. It shouldn’t need a special API for a replicated Map. It shouldn’t have to reorganize an existing class hierarchy because the persistence layer prefers a different shape.

The implementation got increasingly weird so the application model could remain increasingly boring, because boring application code is a feature. Plexus introduces multiple concepts, but at the core level, the goal is to make developers learn only the semantics your application actually needs, not the accidental semantics of the replication engine.

A lot of libraries introduce themselves with: “Here is our model. Learn it.”

The kind of abstraction I wanted instead says: “You already know most of this. Keep using it.”

There are a few new ideas, because distributed state has real semantics that can’t simply disappear, but those ideas should be the ones the application actually needs, rather than accidental details of the replication engine.

Initially I thought Plexus was basically “make Yjs look like TypeScript classes”, but it became less and less accurate as the system grew.

Once the class hierarchy determines schema, fields determine replicated structure, collections keep their native semantics, and object behavior remains attached to the model, the classes aren’t really a facade over Yjs anymore; the application model is becoming the source of truth.

Plexus derives a replication model from it, and its distinction ended up mattering way more than I expected, causing the next problem.

text.container = frame;

If text and frame are replicated objects on several independent peers, I still want that to mean what it looks like it means "container is a reference to this actual object."

I didn't want application code to turn into something like this, mixed with manual lookup tables and lifecycle rules.

text.containerId = frame.id;

I wanted to just assign classes as fields.

Keeping fields looking like fields was mostly a JavaScript problem; keeping object references looking like object references turned into a distributed identity problem that actually led to the paper I’m prepping for arXiv.

That’s where I stopped merely abusing JavaScript and started properly abusing Yjs.

── more in #developer-tools 4 stories · sorted by recency
── more on @plexus 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-abused-js-to-abuse…] indexed:0 read:14min 2026-10-01 · —