JSONRegistry - Allows branded instances in regular JSON

I'm really spending a lot of time pointing out a genuine issue in your design, so the accusations are a bit of of place. I'm not sure where I'm failing at communicating the problem that you don't seem to understand.

Here is a more spelled our example. I assumed someone with your experience would have been able to connect the dots.

Not proofed because I really don't have time.

class Foo {
  #bar;

  get bar() {
    return this.#bar;
  }

  initBar(bar) {
    if (this.#bar !== undefined) throw Error();
    this.#bar = bar;
  }

  static isFoo(foo) {
    return #bar in foo;
  }

  static asClonePayload(foo) {
    return { bar: foo.#bar };
  }

  static fromClonePayload(data) {
    const foo = new Foo();
    foo.initBar(data.bar);
    return foo;
  }
}

class Bar {
  #foo;

  constructor(foo) {
    this.#foo = foo;
  }

  get foo() {
    return this.#foo;
  }

  static isBar(bar) {
    return #foo in bar;
  }

  static asClonePayload(bar) {
    return { foo: bar.#foo };
  }

  static fromClonePayload(data) {
    return new Bar(data.foo);
  }
}

const registry = new CloneRegistry([
  ['Foo', {
    is: value => Foo.isFoo(value),
    to: value => Foo.asClonePayload(value),
    from: value => Foo.fromClonePayload(value),
  }],
  ['Bar', {
    is: value => Bar.isBar(value),
    to: value => Bar.asClonePayload(value),
    from: value => Bar.fromClonePayload(value),
  }],
]);

const foo = new Foo();
const bar = new Bar(foo);
foo.initBar(bar);

Foo.isFoo(foo) && foo.bar.foo === foo; // true

// No way to implement this with your proposed `from` registry API.
const foo2 = registry.clone(foo); 

Foo.isFoo(foo2) && foo2.bar.foo === foo2; // should be true

as mentioned, I am not willing to talk about my JSONregistry proposal anymore, as you stated that's not going to happen, but I can tell you if the structuredClone idea, with the optional registry that doesn't require any API change, would be more welcomed, I can present a polyfill that tackles those easy to handle issues, because I have an army of converters and I am sure they tackle that. The current JSONProxy idea is dead, I am not fixing anything in there neither, because it's a no-go, accordingly to this discussion, but yet that use case would be easy to fix, but I am not doing that, 'cause it's pointless, since we nuked JSONProxy idea already and I am already a full time employee with a 4yo daughter to take care about any time I don't work ... to put in perspective what I am willing to discuss, and what I won't ... yet, I see no issue in anything you are proposing as an issue, I am afraid if that's me not fully understanding the issue, but I've solved with ease your previous puzzle, that's a library I use daily too, if there are bugs, I am willing to fix that.

I hope this answer makes sense to you, let's stop discussing JSONRegistry or JSON already, shall we? Otherwise, should I open a new issue because I won't take any action around that initial idea, since the goal was to discuss the elephant in the room: nobody can serialize custom types?

FFS, understand that my comment was not about JSON, but about the registry API.

s/JSON/Clone in the example, does that make sense now?

OK, I've seen you've changed the code ... I'll try to find time myself to understand why that wouldn't work with structured clone, thanks for the update (I don't have much time myself so I've skipped the code when I've read JSONRegistry, apologies).

I took the time to investigate inside-out the issue ... basically the example proposed is that new Foo has a new Bar that is lazily initialized with that new Foo "container" ... this serializes fine (new Foo is recursive via Foo->Bar->Circular) but unserialize breaks because Foo needs Bar to unserialize, but Bar needs Foo to be constructed.

The gotcha is evil on purpose because you cannot recreate that new Foo directly, you need to create new Bar a part and then call initBar on foo but you want the result to be one shot:

  • 3 operations to create the problematic foo but ...
  • 2 operations to restore one shot?

Your fooFromClonePayload wants to recreate itself as if bar was possible as constructor but it's kinda obvious that cannot possibly work, the constructor design is broken in there ... here a better example:

class Foo {
  #bar;

  get bar() {
    return this.#bar;
  }

  initBar(bar) {
    if (this.#bar !== undefined) throw Error();
    this.#bar = bar;
  }

  static isFoo(foo) {
    return #bar in foo;
  }

  static asClonePayload(foo) {
    return { bar: foo.#bar.foo === foo ? null : foo.#bar };
  }

  static fromClonePayload(data) {
    const foo = new Foo();
    foo.initBar(data.bar ?? new Bar(foo));
    return foo;
  }
}

class Bar {
  #foo;

  constructor(foo) {
    this.#foo = foo;
  }

  get foo() {
    return this.#foo;
  }

  static isBar(bar) {
    return #foo in bar;
  }

  static asClonePayload(bar) {
    return { foo: bar.#foo };
  }

  static fromClonePayload(data) {
    return new Bar(data.foo);
  }
}

I believe this is the correct implementation and it should work ... it does in flatted-view I think it'd do even with my JSON registry proposal.

If the argument is "what should we do in such cases?" I think throwing is fine because these cases simply break the construction/destruction pattern and are kinda pointless to me as examples when the contract is clear: you provide a way to recreate an instance ... if that instance can be cyclic due lazy steps pollution of its fields you gotta deal with it ... would that work?

another way to solve this, when cyclic special instances are expected, could be this one:

const bars = new WeakMap;

class Foo {
  #bar;

  get bar() {
    return this.#bar;
  }

  initBar(bar) {
    if (this.#bar !== undefined) throw Error();
    this.#bar = bar;
  }

  static isFoo(foo) {
    return #bar in foo;
  }

  static asClonePayload(foo) {
    return foo.#bar.foo === foo ? null : foo.#bar;
  }

  static fromClonePayload(bar) {
    const foo = new Foo;
    if (!bar) {
      bar = new Bar(foo);
      bars.set(foo, bar);
    }
    foo.initBar(bar);
    return foo;
  }
}

class Bar {
  #foo;

  constructor(foo) {
    this.#foo = foo;
  }

  get foo() {
    return this.#foo;
  }

  static isBar(bar) {
    return #foo in bar;
  }

  static asClonePayload(bar) {
    return bar.#foo;
  }

  static fromClonePayload(foo) {
    return bars.get(foo) ?? new Bar(foo);
  }
}

I guess what I am trying to say is that if a user wants to play "seppuku" there are solutions to avoid that but that's a user excercise, it's nothing strictly related to the proposal which can work for most common scenarios (and cycles are not a common scenario, these are rather edge cases 'cause these are throwing in JSON and non-existent in structuredClone due its well known compatible instances that never carry cycles, just cross-reference, eventually).

I think you're missing my point. This is an overly simplified case, but in general complex object graphs are not trees, they're graphs, and you cannot assume any part of the transitive links may not circle back onto an object closer to the root. If all objects were immutable we'd never have cycles. But the reality is that objects graphs are often created over multiple state changes, which can introduce cycles.

The objects being revived from serialized state may not know themselves the type/state of the objects they directly or indirectly link to.

In my example I tried to emphasize that Foo and Bar where independently implemented. Your "solution" was effectively to introduce a dependency of Foo's implementation onto the Bar constructor, which breaks that independence. Now make this generic where Foo can hold anything, not just a Bar object.

The right approach is either:

  • a different, 2 step create + init API for deserialized
  • forbid cycles where custom types are involved

I think this is dismissive of complex applications where not all code is implemented by the same person. By your same reasoning, engines shouldn't have complex garbage collection besides simple reference counting because users are causing problems for themselves if they create cycles in their data.

but users can create memory leaks indeed so I am not sure I am getting the friction in here ... it is not possible to send custom data and data with cycles already breaks so anything that works would be better than nothing to me. 2 steps API to deserialize is trivial, my proposed "contract" doesn't dictate how you transform out/in your instances but also breaking on cycles would be fine, although for known cases it's possible to solve that.

In every other case where objects travel as plain object literals there are also no issues, or better, these have been solved in flatted or other libraries because there's no serialization/deserialization issue and entries can be created on demand keeping the reference intact so for cyclic data based cases we already have solutions, for custom types different from what structured clone support we need something that works ... anything, really, without feature-creep but also it's OK if something throw.

Sure but not through cycles. My point is that the JS engine implementations have supported collecting cycles because it's such an common occurrence in complex use cases.

WDYM? structured clone does support data with cycles.

It does, your API is built around a single call for reviving instances. As I said, that doesn't support cycles where multiple independent types are part of the cycle, there is just no way for the type hooks to work around that.

What does a "known case" mean exactly? The fully problematic case is any cycle involving 2 or more objects with custom types, but it's also possible to get in weird situations where only a single custom type object is part of the cycle. Either the serialization/registry API detects cycles with custom types and throws, or it does it more generally for any cycle, including the one where custom types are not involved. Main question is what are the developer expectations.

My goal here is raise awareness of this complication, and that how to handle it is a question to answer as part of any proposal related to custom type serialization.

so does flatted and others ... it's easy with references such as arrays or object literal, it does not work with constructors because there are no cycles in native JS APIs and there's no way to brand differently in a standard way ... can we find that way? That's all this post is about: we know the issue, we have zero solutions to offfer to users ... can we do something about that?

I'd be OK throwing on cycles, it's still better than nothing.

As side note, let's try to be also more pragmatic ... newer APIs might abuse the toJSON one way serialization, as seen in TrustedHTML: toJSON() method - Web APIs | MDN

That ends up as a plain string nobody has any clue how to deal with.

The original proposal of this thread was to make that trusted type serializable in a way that can be resurrected later on.

const { parse, stringify } = JSONRegistry({ TrustedHTML: ... });

The same could be done for IndexedDB in case the StructuredClone counter-proposal lands in some shape or form, but it can be SQLite or any other DB as well to resurrect parts of the stack that were previously computed already.

These are the use cases I am after more than anything else ... I don't want TC39 or WHATWG to implement every single new thing that can be serialized to land into StructuredClone, I'd like to have my way for Proxies (now throwing all over) classes without cycles, well defined, self contained, instances that simply hold their data, and so on.