{"slug": "i-abused-js-to-abuse-yjs", "title": "I abused JS to abuse Yjs", "summary": "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.", "body_md": "*Because I just wanted to keep my TypeScript classes.*\n\nI 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://plexus.here.build/), [https://github.com/here-build/plexus](https://github.com/here-build/plexus)).\n\nI 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.\n\nThen I needed it to become collaborative and local-first.\n\nThe 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.\n\nDisclaimer on AI usage inside Plexus:\n\nPackage 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.\n\nDocumentation was originally handwritten; AI only participated in structuring it and proofreading.\n\nYjs is a CRDT engine.\n\nCRDT, 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.\n\nIt 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.\n\nThat’s great. But if you use those structures directly, they very quickly become your application model.\n\nInstead of this:\n\n```\nclass Component {\n  name = \"\";\n  children: Component[] = [];\n}\n```\n\nyou start writing something more like\n\n``` js\nconst component = new Y.Map();\n\ncomponent.set(\"name\", \"\");\n\nconst children = new Y.Array();\ncomponent.set(\"children\", children);\n```\n\nAnd 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.\n\nThere is nothing wrong with that API; I just already had an application model, and I didn’t want another one.\n\nThe model I had was already built around classes and MobX decorators. It looked roughly like this:\n\n```\nclass DataSourceDefinition implements HasLineage {\n  @observable name: string;\n  @observable label?: string;\n  @observable description?: string;\n  @observable category?: string;\n  @observable type: \"query\" | \"mutation\" | CustomFunction | DataQueryFetch;\n  @observable papp: PartialApplication;\n  @observable extends?: DataSourceDefinition;\n  @observable.shallow invalidates: DefinitionInvalidationTarget[] = [];\n  @observable.shallow externalLinks: Record<string, string> = {};\n}\n```\n\nThe 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.\n\nTo still be thinking in terms of Component, name, and hidden was more or less the design constraint that turned into Plexus.\n\nNot *“make Yjs easier to use”* but *“still keep not thinking about Yjs outside the connection layer.”*\n\nUnfortunately, “just keep writing classes” turns out to be a surprisingly demanding requirement.\n\nThe first problem I’ve encountered was the decorators.\n\nThere 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.\n\nStage-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.\n\nStage-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.\n\nTo explain the effort of “polymorphic decorators”, look [how MobX handles them](https://github.com/mobxjs/mobx/blob/main/packages/mobx/src/types/observableannotation.ts).\n\nA decorator on an auto-accessor can replace its getter and setter:\n\n```\nfunction syncing(target, context) {\n  return {\n    get() {\n      // read replicated/reactive state\n    },\n\n    set(value) {\n      // update replicated/reactive state\n    },\n  };\n}\n```\n\nThat means application code still gets to do this:\n\n```\ncomponent.name\ncomponent.name = \"Header\"\n```\n\nWhile 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.\n\nSuppose I want this API:\n\n```\nclass PlexusModel {\n  constructor(init) {\n    Object.assign(this, init);\n  }\n}\n\nclass Foo extends PlexusModel {\n  @syncing\n  accessor answer = 67;\n}\n\nnew Foo({\n  answer: 69,\n});\n```\n\nSemantics are obvious. 67 is the schema default; 69 is an explicit constructor argument.\n\nBut that’s an application-level interpretation. JavaScript just has initialization order. 69 (nice) should win. But we’re in 2026, meaning 67 wins.\n\nThe 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.\n\nThis 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.\n\nIn simplified form, to solve that problem Plexus does something like:\n\n```\nreturn {\n  get() {\n    // ...\n  },\n\n  set(value) {\n    // ...\n  },\n\n  init(defaultValue) {\n    this.answer = this.initializationData.answer ?? defaultValue;\n    return this.answer;\n  },\n};\n```\n\nBut 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.\n\nI originally reached for decorators because I wanted less framework in my application code, and this required learning far too much about decorators.\n\nClasses aren’t particularly useful if your abstraction only works until somebody writes extends. One of the weirdest issues is that one:\n\n```\nclass Foo {\n  name: string = \"foo\";\n}\n\nclass FooBar extends Foo {\n  name: \"foo\" | \"bar\";\n}\n\nnew FooBar().name // -> undefined\n```\n\nOn JS side, it turns into this:\n\n```\nclass FooBar extends Foo {\n  name;\n}\n\n// internally, it behaves this way:\n\nclass FooBar extends Foo {\n  name = undefined;\n}\n```\n\nWhen 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.\n\nSince Yjs does not have native `undefined` type, the answer became possible - just say *“`undefined` is impossible, `null` and others are actually overwriting.”*\n\nOn top of that, I had another problem. I needed child-parent relations, meaning that it can be overridden. Consider:\n\n```\nclass Parent extends PlexusModel {\n  @syncing\n  accessor value = \"...\";\n}\nclass Child extends Parent {\n  @syncing.child\n  accessor value;\n}\n```\n\nThe 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.\n\nStage-3-style decorators expose metadata through `context.metadata`, which sounds convenient until inheritance enters the room.\n\nPlexus ends up giving the metadata schema an actual prototype chain:\n\n```\nif (!Object.hasOwn(context.metadata, \"schema\")) {\n  context.metadata.schema = {\n    __proto__: context.metadata.schema ?? {},\n  };\n}\n```\n\nYes, that is a prototype chain inside decorator metadata mirroring another prototype chain that exists because the application model itself uses inheritance.\n\nAt some point you stop asking whether you’re abusing JavaScript and start asking whether JavaScript seems to enjoy it.\n\nThere 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:\n\n```\nclass Component {\n  @syncing accessor type: string;\n}\n\nclass PageComponent extends Component {\n  type = \"page\";\n}\n\nclass ImageComponent extends Component {\n  type: \"image\" | \"inline-svg\";\n}\n```\n\nThis 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.\n\nInside the `PlexusModel` class, this humongous hack happens:\n\n```\nObject.defineProperties(\n      this,\n      Object.fromEntries(\n        Object.keys(this.__schema__).map((key) => {\n          let prototype = Object.getPrototypeOf(this);\n          while (prototype && prototype !== prototype.__proto__) {\n            if (Object.hasOwn(prototype, key)) {\n              break;\n            }\n            prototype = prototype.__proto__;\n          }\n          invariant(\n            prototype,\n            `Plexus<${(this.constructor as PlexusConstructor).modelName}>: schema field \"${key}\" not found in prototype chain`,\n          );\n          return [\n            key,\n            // this helps us auto-correct user's mistakes when instead of accessor declaration of schema field\n            // prop declaration is used - this only happens in children of synced elements, thus, we just need to override\n            // \"wrong\" field with its actual behavior.\n            // this also makes all of them enumerable of course. examples of \"why it's needed\" are in inheritance tests\n            {\n              ...Object.getOwnPropertyDescriptor(prototype, key),\n              enumerable: true,\n              configurable: true,\n            } satisfies PropertyDescriptor,\n          ] as const;\n        }),\n      ),\n    );\n```\n\nSo 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.\n\nThe library is effectively saying: *“I know what you meant. Please stop helping.”*\n\nThat 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.\n\nThis became a recurring pattern.\n\nIf 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.\n\nFor example, imagine this perfectly ordinary code:\n\n```\nclass Project extends PlexusModel {\n  @syncing.map\n  accessor components = new Map<string, Component>();\n}\n```\n\nWhat should this do?\n\n```\nproject.components.set(\"header\", header);\nproject.components.get(\"header\");\nproject.components.has(\"header\");\nfor (const [id, component] of project.components) {\n  // ...\n}\n```\n\nMy answer was: exactly what it looks like. I didn't want something like this:\n\n```\nproject.components.setReplicated(...)\n// or\nproject.components.yMap.set(...)\n```\n\nor 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.\n\nThis is where things get properly stupid.\n\nThe 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.\n\nBut from application code, it should still be a Map.\n\nThe implementation eventually contains this:\n\n```\nReflect.setPrototypeOf(self, Map.prototype);\nObject.freeze(self);\n```\n\nThis is an object implementing the Map surface, followed by: “Fine. You’re a Map now”. And that’s not even the worst one.\n\nPlexus 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:\n\n``` js\nconst target = {};\n\nconst self = new Proxy(target, {\n  getPrototypeOf() {\n    return Uint8Array.prototype;\n  },\n// ...\n})\n\n// so that\nself instanceof Uint8Array // -> true\n```\n\nThe 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.\n\nThe 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.\n\nI think at that point the title of this post became unavoidable.\n\nAt 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.\n\nIf a future runtime adds a mutating method and Plexus doesn’t know about it, blindly forwarding it could bypass synchronization.\n\nSo the code classifies the native method surface:\n\n``` js\nconst UINT8ARRAY_METHODS = {\n  readonly: [\n    \"slice\",\n    \"map\",\n    \"find\",\n    // ...\n  ],\n\n  mutating: [\n    \"set\",\n    \"fill\",\n    \"sort\",\n    // ...\n  ],\n\n  banned: [\n    \"subarray\",\n  ],\n} as const satisfies Record<string, ReadonlyArray<MethodsOf<Uint8Array>>>;\n\n// and then has a type-level exhaustiveness check.\n\ntype MissingMethods = Exclude<\n  MethodsOf<Uint8Array>,\n  KnownMethods\n>;\n\ntype MustBeNever =\n  AssertNever<MissingMethods>;\n```\n\nSo 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.\n\nTaken individually, a lot of this looks like unnecessary cleverness.\n\nWhy use decorator initialization hooks?\n\nWhy mirror inheritance through metadata prototypes?\n\nWhy repair descriptors?\n\nWhy make fake Maps?\n\nWhy impersonate a Uint8Array with a Proxy around {}?\n\nBecause 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.\n\nThe 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.\n\nA lot of libraries introduce themselves with: “Here is our model. Learn it.”\n\nThe kind of abstraction I wanted instead says: “You already know most of this. Keep using it.”\n\nThere 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.\n\nInitially I thought Plexus was basically *“make Yjs look like TypeScript classes”*,  but it became less and less accurate as the system grew.\n\nOnce 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.\n\nPlexus derives a replication model from it, and its distinction ended up mattering way more than I expected, causing the next problem.\n\n```\ntext.container = frame;\n```\n\nIf 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.\"*\n\nI didn't want application code to turn into something like this, mixed with manual lookup tables and lifecycle rules.\n\n```\ntext.containerId = frame.id;\n```\n\nI wanted to just assign classes as fields.\n\nKeeping 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.\n\nThat’s where I stopped merely abusing JavaScript and started properly abusing Yjs.", "url": "https://wpnews.pro/news/i-abused-js-to-abuse-yjs", "canonical_source": "https://dev.to/merkle_bonsai/i-abused-js-to-abuse-yjs-69m", "published_at": "2026-10-01 12:40:31+00:00", "updated_at": "2026-10-01 12:44:18.130829+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Plexus", "Yjs", "TypeScript", "MobX", "here-build"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/i-abused-js-to-abuse-yjs", "markdown": "https://wpnews.pro/news/i-abused-js-to-abuse-yjs.md", "text": "https://wpnews.pro/news/i-abused-js-to-abuse-yjs.txt", "jsonld": "https://wpnews.pro/news/i-abused-js-to-abuse-yjs.jsonld"}}