JSON

Loft has built-in JSON support: any struct can be serialised to JSON with a format flag, and parsed back from JSON text. No annotations or code generation needed — it works on every struct automatically.

Serialisation — struct to JSON

Use the :j format flag inside a format string to produce JSON output. Field names become quoted keys; strings are escaped; numbers and booleans are written as JSON literals.

"{my_struct:j}"
struct User {
  id: integer,
  name: text,
  email: text
}
struct MaybeUser {
  id: integer,
  name: text?,
}

Parsing — JSON to struct

Call Type.parse(text) to create a struct from JSON text. Text arguments are auto-wrapped through json_parse internally.

A field the JSON does not mention — and a field written null — gets the DECLARED types absent value. For a plain field that is its zero, because a plain field cannot hold null; write the field integer? / float?' if you need to tell "absent" from "zero" apart:

struct Reading { id: integer, drift: float? }
r = Reading.parse("{}")     // r.id == 0, r.drift == null

Caveat (Q1): the auto-wrap form DROPS diagnostics — malformed input and schema mismatches leave the struct at its defaults with json_errors() empty. For error reporting, stage explicitly: User.parse(json_parse(text)) — that form pushes both parse and schema errors to json_errors().

user = User.parse(json_text)

to inspect json_errors() between the two steps.

Vectors

Parse a JSON array into a vector of structs with vector<T>.parse(text).

scores = vector<Score>.parse("[{\"v\":1},{\"v\":2}]")
struct Score {
  value: integer
}

Parse Errors

Call json_errors() to see what went wrong with a malformed input. Schema-level mismatches (e.g. a field declared integer but receiving a JSON string) currently land as the loft null sentinel in the struct; Q1 schema-side diagnostics will add path-qualified reports in a follow-up.

data = MyType.parse(bad_json);
if len(json_errors()) > 0 { log_warn(json_errors()); }

Nested Structs

Structs with struct-typed fields parse nested JSON objects automatically.

struct Address {
  city: text,
  zip: text
}
struct Contact {
  name: text,
  address: Address
}
fn main() {
  u = User { id: 42, name: "Alice", email: "alice@example.com" };
  json = "{u:j}";
  expected = `{{"id":42,"name":"Alice","email":"alice@example.com"}}`;
  assert(json == expected, "to json");
  bob = User.parse(`{{"id":7,"name":"Bob","email":"bob@test.org"}}`);
  assert(bob.id == 7, "parsed id: {bob.id}");
  assert(bob.name == "Bob", "parsed name");
  assert(bob.email == "bob@test.org", "parsed email");
  u2 = User.parse("{u:j}");
  assert(u2.id == u.id, "round-trip id");
  assert(u2.name == u.name, "round-trip name");

Type-mismatched fields (id: string, name: number) parse as JSON fine, but the struct unwrap abandons the record at its defaults; path-qualified diagnostics on the mismatch are collected in json_errors. id is declared plain, so its default is 0 — the mismatch is reported through json_errors, never by putting a null in a slot the declared type says cannot hold one. Here we verify the unwrap does not crash on mismatched shapes.

  bad = User.parse(`{{"id":"not_a_number","name":42}}`);
  assert(bad.id == 0, "type-mismatched id keeps its default: {bad.id}");
  scores = vector<Score>.parse(`[{{"value":10}},{{"value":20}},{{"value":30}}]`);
  total = 0;
  for s in scores {
    total += s.value;
  }
  assert(total == 60, "vector sum: {total}");
  c = Contact.parse(`{{"name":"Carol","address":{{"city":"Amsterdam","zip":"1012"}}}}`);
  assert(c.name == "Carol", "nested name");
  assert(c.address.city == "Amsterdam", "nested city: {c.address.city}");
  assert(c.address.zip == "1012", "nested zip");

A missing field gets its own declared absence

When a field is absent from the JSON, it gets the absent value its DECLARATION allows — not the types null sentinel. A plain field cannot hold null, so it takes its empty value: 0 for a number, "" for text, false for a boolean. Declare the field T? when "the document did not say" has to be tellable from "the document said zero", and check it with ! or ??' before use.

  partial = User.parse(`{{"id":1}}`);
  assert(partial.id == 1, "partial id");
  assert(partial.name == "", "a missing `text` field is empty: [{partial.name}]");
  maybe = MaybeUser.parse(`{{"id":1}}`);
  assert(!maybe.name, "a missing `text?` field is null");
}