Manual JSON

JSON

elements man json Read as markdown

Elements ships with its own json serializer. The output is valid json so anything that speaks json can read it, but the format also carries class identity, cycles, and repeated references. Standard json doesn't handle any of those. A class like User arrives on the browser as a plain object, without instanceof User or any methods. A graph containing a cycle throws. Shared subgraphs serialize their inner objects over and over.

Elements json handles all three. A class marked with the @json decorator round-trips with its identity intact: the same User instance leaves the server and arrives on the browser as a User instance. Repeated references serialize once and back-reference on every subsequent appearance. Cycles round-trip without infinite recursion. Date, Map, Set, LiveTable, Listener, and File all have built-in handlers that preserve their type.

Every data boundary in Elements uses this serializer automatically. You don't call stringify() yourself to send data across the wire; Elements does it wherever data crosses a boundary:

  • @rpc arguments and return values.
  • Route handler returns from route() to the html page.
  • Plain objects and arrays returned from api route handlers.
  • Channel.notify(...) payloads and listener messages.
  • LiveTable broadcasts.

Mark a custom class @json and its instances flow through any of those paths with class identity restored on the other side. You can also call the helpers directly when you need a string in hand.

import { stringify, parse } from "@elements/app";

let s = stringify(value);   // EJSON string
let v = parse(s);           // value with class identity restored

At a Glance

import { json, stringify, parse } from "@elements/app";

@json
class User {
  id: string;
  name: string;
  createdAt: Date;

  constructor(id: string = "", name: string = "", createdAt: Date = new Date()) {
    this.id = id;
    this.name = name;
    this.createdAt = createdAt;
  }
}

let user = new User("u1", "alice", new Date());

let s = stringify(user);      // EJSON string
let u: User = parse(s);       // round-trips with class identity intact
u instanceof User;            // true
u.createdAt instanceof Date;  // true

The @json Decorator

@json is a TypeScript decorator. Apply it to any class you want to round-trip through Elements json with its class identity intact.

import { json } from "@elements/app";

@json
class Comment {
  id: string;
  text: string;
  author: string;
}

Without @json, a class instance serializes as a plain object on the wire. The browser receives a plain object, not an instance of the class. For plain data shapes that's exactly what you want, and you don't need the decorator. Use @json when you want class identity preserved.

The decorator registers the class by name in a global registry. The serializer emits { "$type": "<class name>", "$value": { ... } }. The deserializer looks up the class and reconstructs the instance.

What the Serializer Handles

  • null, undefined, boolean, number, string: passed through.
  • Date: emitted as { "$type": "Date", "$value": <epoch millis> }.
  • Map, Set: emitted with their entries preserved.
  • Array, Object: recursive.
  • Repeated reference: emitted as { "$type": "ref", "$value": "<path>" }. The serializer emits the value once and refs it on subsequent appearances.
  • Cycles: resolved via ref. A node referencing itself, directly or transitively, round-trips without infinite recursion.
  • @json-decorated class instances: emitted as { "$type": "<name>", "$value": { ... } }.
  • Listener, LiveTable, File: built-in types with their own handlers.

Custom Serialization

For classes that need control over their wire shape, implement the [ToJSON] and [FromJSON] symbols.

import { json, ToJSON, FromJSON, JSONType } from "@elements/app";

@json
class Money {
  cents: number;
  currency: string;

  constructor(cents: number = 0, currency: string = "USD") {
    this.cents = cents;
    this.currency = currency;
  }

  [ToJSON]() {
    return { c: this.cents, ccy: this.currency };
  }

  static [FromJSON](value: { c: number; ccy: string }): Money {
    return new Money(value.c, value.ccy);
  }

  static [JSONType]() {
    return "Money";
  }
}
  • [ToJSON](): instance method that returns the wire shape. The default copies own properties.
  • [FromJSON](/learn/man/value): static method that reconstructs the instance. The default does Object.assign(new Class(), value).
  • [JSONType](): static method that returns the type name used on the wire. The default is the class name.

Once the class is registered via @json, the symbols are picked up automatically.

Helpers

import { stringify, parse, serialize, deserialize } from "@elements/app";

let s = stringify(value);                          // EJSON string
let v = parse(s);                                  // round-trip

let s2 = serialize(value, replacer, "  ");         // JSON.stringify-style options
let v2 = deserialize(s2);

stringify and serialize are aliases. parse and deserialize are aliases.

The replacer and space arguments mirror JSON.stringify. The replacer runs after Elements has computed the type tag, so it sees the typed shape.

To pass a space (indent) without a replacer, pass undefined for the replacer, not null:

let pretty = stringify(value, undefined, "  ");   // indented, no replacer

Attach

After deserialization, classes that need post-init wiring implement an [Attach]() method. The deserializer calls [Attach]() on each instance after the entire graph has been reconstructed and references are resolved.

Elements' built-in types (Listener, LiveTable) use this to connect to the runtime. Application classes can implement it too if they need the same kind of post-deserialization setup.

Related

  • rpc: arguments and return values are serialized with this.
  • router: route returns to html pages and api route returns both go through this.
  • livetable: broadcasts use this serializer.
  • channel: notify payloads use this serializer.