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
- A missing field takes its default (
city) ornoneif 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
BigIntfield takes a whole number of any size, aDecimalfield any number exactly, and aFloatfield any number, as the nearestFloat.
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`")])
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
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
- The problem's
pathis 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_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)."}
- Decoding reads the text as time.parse_date and
time.parse_timedo, so digits of a second past milliseconds are dropped. - A
DateTimemay end inZ, in an offset, or in neither. Anything else, such as PostgreSQL'sinfinity, is a problem that says what was expected. - In a schema, a
DateTimehas"format": "date-time", and aTimea"pattern", since JSON Schema'stimeformat needs an offset.