Skip to content
Kavachकवच

JSON: Decoding into a type

● Intermediate3 min

json.decode<T> reads JSON text straight into your type T, and says exactly where it doesn't fit.

use std.json

type User {
    name: String
    city: String = "Pune"
    nickname: String?
}

fn main() ! Error {
    const text = "\{\"name\": \"Asha\"}"
    const user = try json.decode<User>(text)
    print(user.name)
    print(user.city)
    print(user.nickname ?? "no nickname")
}

The program ran and printed:

kavach run decode-fields.kav

AshaPuneno nickname
decode-fields.kav: Kavach code, then what running it prints
  • A missing field takes its default (city) or none if it is optional (nickname).
  • A field your type doesn't have is refused too, so a misspelled or smuggled field is caught. See below for reading a service that may add fields.
  • Decoding is strict about values: an integer field takes only a number written without a fraction or exponent that fits its type.
  • A BigInt field takes a whole number of any size, a Decimal field any number exactly, and a Float field any number, as the nearest Float.

A number that doesn't fit its field is refused like any other value that doesn't fit, never rounded:

use std.json

type Order {
    id: Int
}

type BigOrder {
    id: BigInt
}

fn main() ! Error {
    const text = "\{\"id\": 12345678901234567890}"
    const big = try json.decode<BigOrder>(text)
    print(big.id)
    const order = json.decode<Order>(text) catch e => {
        print("rejected: {e}")
        return
    }
    print(order.id)
}

The program ran and printed:

kavach run too-big.kav

12345678901234567890rejected: Invalid(problems: [JsonProblem(path: "id", expected: "a whole number that fits `Int`")])
too-big.kav: Kavach code, then what running it prints

Decoding goes on after a problem, so JsonError.Invalid lists every place the text doesn't fit, in order: where it is (path), and what the type wanted there (expected). A service can answer with all of them at once:

use std.json

type Item {
    name: String
    qty: Int
}

fn main() {
    const text = "[\{\"name\": 5, \"qty\": 1}, \{\"qty\": 2}, \{\"name\": \"cup\", \"qty\": \"x\"}]"
    const items = json.decode<[Item]>(text) catch e => match e {
        JsonError.Invalid(problems) => {
            print("{problems.len()} problems:")
            for p in problems {
                print("  {p.path}: {p.expected}")
            }
            []
        }
        _ => [],
    }
    print(items.len())
}

The program ran and printed:

kavach run mismatch.kav

3 problems:  [0].name: text  [1]: a field `name`  [2].qty: a whole number that fits `Int`0
mismatch.kav: Kavach code, then what running it prints

Invariants are checked too

A decoded value is checked against its type's invariants, as any value is when it is made. Text from outside that breaks one is not a mistake in the program, so it is a problem in the list, not a panic:

use std.json

type Item {
    name: String
    qty: Int

    invariant qty > 0
}

fn main() {
    const items = json.decode<[Item]>("[\{\"name\": \"pen\", \"qty\": 2}, \{\"name\": \"cup\", \"qty\": 0}]") catch e => {
        print("rejected: {e}")
        []
    }
    print(items.len())
}

The program ran and printed:

kavach run decode-invariant.kav

rejected: Invalid(problems: [JsonProblem(path: "[1]", expected: "invariant `qty > 0`")])0
decode-invariant.kav: Kavach code, then what running it prints
  • The problem's path is the record's: [1] is the second item.
  • The invariants are checked once every value has the right shape. Until then, the problems are those of shape.

Fields your type doesn't have

A field the type doesn't have is a problem at its path, as the type's schema says. Another service's answers may gain fields over time, so to read them write unknown: JsonUnknown.Ignore, and such fields are skipped:

use std.json

type Order {
    id: Int
}

fn main() {
    const text = "\{\"id\": 1, \"note\": \"leave at the door\"}"
    match json.decode<Order>(text) {
        ok(order) => print("read order {order.id}")
        err(e) => print(e)
    }
    match json.decode<Order>(text, unknown: JsonUnknown.Ignore) {
        ok(order) => print("read order {order.id}, note skipped")
        err(e) => print(e)
    }
}

The program ran and printed:

kavach run decode-unknown.kav

Invalid(problems: [JsonProblem(path: "note", expected: "no field of this name")])read order 1, note skipped
decode-unknown.kav: Kavach code, then what running it prints

decode_value takes unknown too. The std.http client skips unknown fields in the responses it decodes, so a service can add fields without breaking its clients.

Dates and times

std.time's Date, Time and DateTime are ISO 8601 text in JSON, as their iso() writes them. A DateTime gets a Z: JSON holds it in UTC, so other programs read the right moment. An offset in the text is turned to UTC when it is read:

use std.json
use std.time.{Date, DateTime}

type Booking {
    day: Date
    made: DateTime
}

fn read(text: String) {
    match json.decode<Booking>(text) {
        ok(b) => {
            print(b.made.iso())
            print(json.stringify(b))
        }
        err(JsonError.Invalid(problems)) => {
            for p in problems {
                print("[{p.path}] {p.expected}")
            }
        }
        err(e) => print(e)
    }
}

fn main() {
    read("\{\"day\": \"2024-01-02\", \"made\": \"2024-01-01T02:00:00+05:30\"}")
    read("\{\"day\": \"infinity\", \"made\": \"2024-01-02 03:04\"}")
    print(json.schema<Date>())
}

The program ran and printed:

kavach run dates.kav

2023-12-31T20:30:00{"day":"2024-01-02","made":"2023-12-31T20:30:00Z"}[day] a date such as `2024-01-02`[made] a date and time such as `2024-01-02T03:04:05Z`{"type":"string","format":"date","description":"A day of the calendar, with no time and no zone: months 1 to 12, days 1 to the month's last. The calendar is the Gregorian one, for every year, with no exceptions (spec 14.4)."}
dates.kav: Kavach code, then what running it prints
  • Decoding reads the text as time.parse_date and time.parse_time do, so digits of a second past milliseconds are dropped.
  • A DateTime may end in Z, in an offset, or in neither. Anything else, such as PostgreSQL's infinity, is a problem that says what was expected.
  • In a schema, a DateTime has "format": "date-time", and a Time a "pattern", since JSON Schema's time format needs an offset.