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
}
Parsing — JSON to struct
Call Type.parse(text) to create a struct from JSON text. Text arguments are auto-wrapped through json_parse internally, so malformed input lands in json_errors() rather than silently corrupting the struct. Missing fields get null sentinels.
user = User.parse(json_text)
Callers wanting explicit staging can write User.parse(json_parse(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 produces null sentinels. Schema-level diagnostics are a Q1 follow-up — for now verify the unwrap does not crash on mismatched shapes.
bad = User.parse(`{{"id":"not_a_number","name":42}}`);
assert(bad.id == null, "type-mismatched id becomes null: {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");
Missing fields get null
When a field is absent from the JSON, the struct field gets its null sentinel. For text, that is the NUL character \0 (not the empty string ""); for integer it is the minimum i32 value. Check with ! before use, or assign a default with ??.
partial = User.parse(`{{"id":1}}`);
assert(partial.id == 1, "partial id");
assert(!partial.name, "missing name is null");
}