Ship

Book · Serialization

Serialization

Draft

Types decide their wire shape at compile time; formats only read and write it.

On this page · 8 sections

Ordinary concrete data is serializable without ceremony. Json.encode and Json.decode route through each type's Encode and Decode modules, which the compiler derives from the type's shape. The target type alone decides what a document must contain: a format never guesses a representation from the bytes it sees, and nothing is inspected at run time to choose one.

type Signup = {
  email: String;
  age: Int;
  nickname: Option(String);
}

let texttext: fn(body: Bytes) -> String = (bodybody: Bytes: Bytes) =>
  Bytes.slice_stringslice_string: pure fn!(bytes: Bytes, start: Int, end: Int) -> String(bodybody: Bytes, 00: Int, Bytes.lengthlength: pure fn!(bytes: Bytes) -> Int(bodybody: Bytes));

let signupsignup: Book.Serialization.Signup = Signup {
  email = "ada@example.com""ada@example.com": String;
  age = 3636: Int;
  nickname = None;
};

Round Trips

test✓
  let bodybody: Bytes = must Json.encodeencode: pure fn('a: Encode)(value: Book.Serialization.Signup) -> Bytes, exn [ :encode(Wire.EncodeError) ](signupsignup: Book.Serialization.Signup);
  expect ✓texttext: fn(body: Bytes) -> String(bodybody: Bytes) == #"{"email":"ada@example.com","age":36,"nickname":null}"##"{"email":"ada@example.com","age":36,"nickname":null}"#: String;
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Signup), ~max_depth: Int = 128) -> Book.Serialization.Signup, exn [ :decode(Wire.DecodeError) ](bodybody: Bytes, Signup) == signupsignup: Book.Serialization.Signup;

Decode errors

A failed decode reports what it expected, the path to the offending value, and its byte offset. A field the type declares is required unless it has a default; None is spelled null, not by leaving the field out.

let json_failurejson_failure: fn('target: Decode)(body: String, target: Struct('target)) -> String = fn('target: Decode)(bodybody: String: String, targettarget: Struct('a): Struct('target)) {
  match Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct('target), ~max_depth: Int = 128) -> 'target, exn [ :decode(Wire.DecodeError) ](Bytes.of_stringof_string: pure fn!(string: String) -> Bytes(bodybody: String), targettarget: Struct('a)) {
    | _ -> "decoded""decoded": String
    | error :decode(failurefailure: Wire.DecodeError) -> failurefailure: Wire.DecodeError.messagemessage: String ++ " at "" at ": String ++ Wire.Path.to_stringto_string: pure fn!(path: Vector(Wire.Path.Segment)) -> String(failurefailure: Wire.DecodeError.pathpath: Vector(Wire.Path.Segment))
  }
};

Located Errors

test✓
  expect ✓json_failurejson_failure: fn('target: Decode)(body: String, target: Struct(Book.Serialization.Signup)) -> String(
    #"{"email":"a","age":"old","nickname":null}"##"{"email":"a","age":"old","nickname":null}"#: String,
    Signup,
  ) == "expected Int at \$.age""expected Int at \$.age": String;
  expect ✓json_failurejson_failure: fn('target: Decode)(body: String, target: Struct(Book.Serialization.Signup)) -> String(#"{"email":"a","age":1}"##"{"email":"a","age":1}"#: String, Signup)
    == "missing required field `nickname` at \$""missing required field `nickname` at \$": String;
  expect ✓json_failurejson_failure: fn('target: Decode)(body: String, target: Struct(Book.Serialization.Signup)) -> String(
    #"{"email":"a","age":1,"nickname":null,"admin":true}"##"{"email":"a","age":1,"nickname":null,"admin":true}"#: String,
    Signup,
  ) == "unknown field `admin` at \$""unknown field `admin` at \$": String;

  let offsetoffset: Option(Int) = match Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Signup), ~max_depth: Int = 128) -> Book.Serialization.Signup, exn [ :decode(Wire.DecodeError) ](
    b"{\"email\":\"a\",\"age\":\"old\",\"nickname\":null}"b"{\"email\":\"a\",\"age\":\"old\",\"nickname\":null}": Bytes,
    Signup,
  ) {
    | _ -> NoneNone: Option(Int)
    | error :decode(failurefailure: Wire.DecodeError) -> failurefailure: Wire.DecodeError.offsetoffset: Option(Int)
  };
  expect ✓offsetoffset: Option(Int) == SomeSome: fn(Int) -> Option(Int)(1919: Int);

Decoding recurses once per level of nesting, so every reader bounds the nesting it accepts. max_depth defaults to 128.

type Tree =
  | Leaf
  | Node(Vector(Tree))

type Grid = Vector(Vector(Vector(Vector(Int))))

Depth Limits

test✓
  expect ✓json_failurejson_failure: fn('target: Decode)(body: String, target: Struct(Book.Serialization.Tree)) -> String(#"{"Node":[{"Node":["Leaf"]}]}"##"{"Node":[{"Node":["Leaf"]}]}"#: String, Tree) == "decoded""decoded": String;

  let messagemessage: String = match Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Vector(Vector(Vector(Vector(Int))))), ~max_depth: Int = 128) -> Vector(Vector(Vector(Vector(Int)))), exn [ :decode(Wire.DecodeError) ](b"[[[[1]]]]"b"[[[[1]]]]": Bytes, Grid, max_depth = 33: Int) {
    | _ -> "decoded""decoded": String
    | error :decode(failurefailure: Wire.DecodeError) -> failurefailure: Wire.DecodeError.messagemessage: String
  };
  expect ✓messagemessage: String == "JSON nested deeper than 3 levels""JSON nested deeper than 3 levels": String;

Unknown fields

Products reject fields they do not declare, so a misspelled key fails loudly instead of silently decoding a default. A type that accepts documents from a newer peer says so with Wire.ignore_unknown.

type Heartbeat #{ Wire.ignore_unknown = TrueTrue: Bool; } = {
  id: Int;
}

Ignoring Unknown Fields

test✓
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Heartbeat), ~max_depth: Int = 128) -> Book.Serialization.Heartbeat, exn [ :decode(Wire.DecodeError) ](b"{\"id\":1,\"sent_by\":\"v2\"}"b"{\"id\":1,\"sent_by\":\"v2\"}": Bytes, Heartbeat) == Heartbeat { id = 11: Int; };

Variants

Variants are externally tagged by default: a unit case is its name, and a case with a payload is a one-entry object. Wire.name renames a case or field on the wire.

type Shape =
  | Circle(Float)
  | Square { side: Int; }
  | Empty #{ Wire.name = "none""none": String; }

External Tags

test✓
  expect ✓texttext: fn(body: Bytes) -> String(
    must Json.encodeencode: pure fn('a: Encode)(value: Vector(Book.Serialization.Shape)) -> Bytes, exn [ :encode(Wire.EncodeError) ]([CircleCircle: fn(Float) -> Book.Serialization.Shape(1.51.5: Float), SquareSquare: fn(sparse named fields (side: Int)) -> Book.Serialization.Shape { side = 22: Int; }, Empty]),
  ) == #"[{"Circle":1.5},{"Square":{"side":2}},"none"]"##"[{"Circle":1.5},{"Square":{"side":2}},"none"]"#: String;
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Shape), ~max_depth: Int = 128) -> Book.Serialization.Shape, exn [ :decode(Wire.DecodeError) ](b"\"none\""b"\"none\"": Bytes, Shape) == EmptyEmpty: Book.Serialization.Shape;

Wire.tag stores the case name inside the payload object instead, and Wire.content nests the payload under its own key. A case marked Wire.other receives every tag no other case declares, so a reader can accept tags added after it shipped.

type Event #{ Wire.tag = "type""type": String; } =
  | Joined { user: String; }
  | Left { user: String; }
  | Unrecognized(String) #{ Wire.other = TrueTrue: Bool; }

type Envelope #{
  Wire.tag = "t""t": String;
  Wire.content = "c""c": String;
} =
  | Ping
  | Count(Int)

Internal And Adjacent Tags

test✓
  expect ✓texttext: fn(body: Bytes) -> String(must Json.encodeencode: pure fn('a: Encode)(value: Book.Serialization.Event) -> Bytes, exn [ :encode(Wire.EncodeError) ](JoinedJoined: fn(sparse named fields (user: String)) -> Book.Serialization.Event { user = "ada""ada": String; })) == #"{"type":"Joined","user":"ada"}"##"{"type":"Joined","user":"ada"}"#: String;
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Event), ~max_depth: Int = 128) -> Book.Serialization.Event, exn [ :decode(Wire.DecodeError) ](b"{\"user\":\"ada\",\"type\":\"Left\"}"b"{\"user\":\"ada\",\"type\":\"Left\"}": Bytes, Event) == LeftLeft: fn(sparse named fields (user: String)) -> Book.Serialization.Event { user = "ada""ada": String; };
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Event), ~max_depth: Int = 128) -> Book.Serialization.Event, exn [ :decode(Wire.DecodeError) ](b"{\"type\":\"Renamed\"}"b"{\"type\":\"Renamed\"}": Bytes, Event) == UnrecognizedUnrecognized: fn(String) -> Book.Serialization.Event("Renamed""Renamed": String);
  expect ✓texttext: fn(body: Bytes) -> String(must Json.encodeencode: pure fn('a: Encode)(value: Book.Serialization.Envelope) -> Bytes, exn [ :encode(Wire.EncodeError) ](CountCount: fn(Int) -> Book.Serialization.Envelope(33: Int))) == #"{"t":"Count","c":3}"##"{"t":"Count","c":3}"#: String;
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Envelope), ~max_depth: Int = 128) -> Book.Serialization.Envelope, exn [ :decode(Wire.DecodeError) ](b"{\"t\":\"Ping\"}"b"{\"t\":\"Ping\"}": Bytes, Envelope) == PingPing: Book.Serialization.Envelope;

Adapters

A different representation for one field is a module, not a flag. A Wire.Codec behavior states both directions once, and Wire.via applies it. Container adapters are functors applied to a codec, and the application can be named like any other module.

module behavior Digits: Wire.Codec(Int) {
  encodeencode: fn('encoder: Wire.Encoder)(value: Int @ 'm, encoder: 'encoder @ 'm) -> Wire.Encoder.Ok('encoder), exn Wire.EncodeFailure = ('encoder: Wire.Encoder)(valuevalue: Int: Int, encoderencoder: 'a: 'encoder) =>
    Wire.Encoder.string(encoderencoder: 'a, Int.to_stringto_string: pure fn!(value: Int) -> String(valuevalue: Int));
  decodedecode: fn('decoder: Wire.Decoder)(decoder: 'decoder) -> Int, exn [ :decode(Wire.DecodeError) ] = fn('decoder: Wire.Decoder)(decoderdecoder: 'a: 'decoder) -> Int, exn [ :decode(Wire.DecodeError) ] {
    let digitsdigits: String = Wire.Decoder.string(decoderdecoder: 'a);
    return match Int.parse_optionparse_option: pure fn!(raw: String) -> Option(Int)(digitsdigits: String) {
      | Some(valuevalue: Int) -> valuevalue: Int
      | None -> Wire.Decoder.reject(
        decoderdecoder: 'a,
        Wire.InvalidValueInvalidValue: fn(sparse named fields (expected: String; found: String; detail: String)) -> Wire.Rejection {
          expected = "decimal digits""decimal digits": String;
          found = digitsdigits: String;
          detail = "not a number""not a number": String;
        },
      )
    };
  };
}

module OptionalDigits = Wire.Optional(DigitsDigits: Wire.Codec(Int));

type Invoice = {
  number: Int #{ Wire.via = DigitsDigits: Wire.Codec(Int); };
  lines: Vector(Int) #{ Wire.via = Wire.Each(DigitsDigits: Wire.Codec(Int)); };
  parent: Option(Int) #{ Wire.via = OptionalDigitsOptionalDigits: Wire.Codec(Option(Int)); };
}

Codec Adapters

test✓
  let invoiceinvoice: Book.Serialization.Invoice = Invoice { number = 77: Int; lines = [11: Int, 2222: Int]; parent = None; };
  expect ✓texttext: fn(body: Bytes) -> String(must Json.encodeencode: pure fn('a: Encode)(value: Book.Serialization.Invoice) -> Bytes, exn [ :encode(Wire.EncodeError) ](invoiceinvoice: Book.Serialization.Invoice)) == #"{"number":"7","lines":["1","22"],"parent":null}"##"{"number":"7","lines":["1","22"],"parent":null}"#: String;
  expect ✓must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Invoice), ~max_depth: Int = 128) -> Book.Serialization.Invoice, exn [ :decode(Wire.DecodeError) ](
    b"{\"number\":\"7\",\"lines\":[],\"parent\":\"6\"}"b"{\"number\":\"7\",\"lines\":[],\"parent\":\"6\"}": Bytes,
    Invoice,
  ).parentparent: Option(Int) == SomeSome: fn(Int) -> Option(Int)(66: Int);
  expect ✓json_failurejson_failure: fn('target: Decode)(body: String, target: Struct(Book.Serialization.Invoice)) -> String(
    #"{"number":"x","lines":[],"parent":null}"##"{"number":"x","lines":[],"parent":null}"#: String,
    Invoice,
  ) == "invalid decimal digits: not a number at \$.number""invalid decimal digits: not a number at \$.number": String;

Keys, bytes, and time

Map keys travel as text. A key type states its text form once with Wire.Key, so Map(Int, _) and Map(String, _) both encode, and a key that does not parse is a located error. Text formats write Bytes as RFC 4648 base64, Instant as RFC 3339, and LocalDate as ISO 8601.

type Upload = {
  counts: Map(Int, String);
  digest: Bytes;
  at: Instant;
  day: LocalDate;
}

Text Forms

test✓
  let uploadupload: Book.Serialization.Upload = must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Upload), ~max_depth: Int = 128) -> Book.Serialization.Upload, exn [ :decode(Wire.DecodeError) ](
    b"{\"counts\":{\"1\":\"one\"},\"digest\":\"aGk=\",\"at\":\"2026-10-06T12:00:00Z\",\"day\":\"2026-10-06\"}"b"{\"counts\":{\"1\":\"one\"},\"digest\":\"aGk=\",\"at\":\"2026-10-06T12:00:00Z\",\"day\":\"2026-10-06\"}": Bytes,
    Upload,
  );
  expect ✓Map.getget: pure fn!('k, 'v)(items: Map(Int, String), key: Int) -> String(uploadupload: Book.Serialization.Upload.countscounts: Map(Int, String), 11: Int) == "one""one": String;
  expect ✓texttext: fn(body: Bytes) -> String(uploadupload: Book.Serialization.Upload.digestdigest: Bytes) == "hi""hi": String;
  expect ✓Instant.to_rfc3339to_rfc3339: pure fn!(instant: Instant) -> String(uploadupload: Book.Serialization.Upload.atat: Instant) == "2026-10-06T12:00:00Z""2026-10-06T12:00:00Z": String;
  expect ✓json_failurejson_failure: fn('target: Decode)(body: String, target: Struct(Book.Serialization.Upload)) -> String(
    #"{"counts":{"x":"one"},"digest":"","at":"","day":""}"##"{"counts":{"x":"one"},"digest":"","at":"","day":""}"#: String,
    Upload,
  ) == "invalid map key `x` at \$.counts.x""invalid map key `x` at \$.counts.x": String;

Documents as values

Wire.Value is what a document literally says, for code that does not know its shape. Objects keep document order, and numbers keep their source text, so no format narrows a number it only passes through. Json.parse builds one, and a Wire.Value field holds any JSON value.

type Hook = {
  event: String;
  payload: Wire.Value;
}

Wire Values

test✓
  let documentdocument: Wire.Value = must Json.parseparse: pure fn(body: Bytes, ~max_depth: Int = 128) -> Wire.Value, exn [ :parse(Json.ParseError) ](b"{\"z\":1,\"a\":12345678901234567890.5}"b"{\"z\":1,\"a\":12345678901234567890.5}": Bytes);
  expect ✓Inspect.inspectinspect: pure fn!('a: Inspect)(value: Wire.Value) -> String(documentdocument: Wire.Value)
    == #"Object({ "z": Number(1); "a": Number(12345678901234567890.5) })"##"Object({ "z": Number(1); "a": Number(12345678901234567890.5) })"#: String;
  expect ✓Inspect.inspectinspect: pure fn!('a: Inspect)(value: Option(Wire.Value)) -> String(Wire.Value.getget: pure fn!(value: Wire.Value, key: String) -> Option(Wire.Value)(documentdocument: Wire.Value, "z""z": String)) == "Some(Number(1))""Some(Number(1))": String;

  let hookhook: Book.Serialization.Hook = must Json.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Hook), ~max_depth: Int = 128) -> Book.Serialization.Hook, exn [ :decode(Wire.DecodeError) ](
    b"{\"event\":\"push\",\"payload\":{\"ref\":\"main\",\"size\":3}}"b"{\"event\":\"push\",\"payload\":{\"ref\":\"main\",\"size\":3}}": Bytes,
    Hook,
  );
  expect ✓texttext: fn(body: Bytes) -> String(must Json.encodeencode: pure fn('a: Encode)(value: Wire.Value) -> Bytes, exn [ :encode(Wire.EncodeError) ](hookhook: Book.Serialization.Hook.payloadpayload: Wire.Value)) == #"{"ref":"main","size":3}"##"{"ref":"main","size":3}"#: String;

Wire.Value is also a format. Wire.to_value writes the tree a type encodes to, and Wire.of_value decodes one, with the same errors.

Value Trees

test✓
  let treetree: Wire.Value = must Wire.to_valueto_value: pure fn('a: Encode)(value: Book.Serialization.Shape) -> Wire.Value, exn [ :encode(Wire.EncodeError) ](SquareSquare: fn(sparse named fields (side: Int)) -> Book.Serialization.Shape { side = 22: Int; });
  expect ✓Inspect.inspectinspect: pure fn!('a: Inspect)(value: Wire.Value) -> String(treetree: Wire.Value) == #"Object({ "Square": Object({ "side": Number(2) }) })"##"Object({ "Square": Object({ "side": Number(2) }) })"#: String;
  expect ✓must Wire.of_valueof_value: pure fn('target: Decode)(value: Wire.Value, target: Struct(Book.Serialization.Shape)) -> Book.Serialization.Shape, exn [ :decode(Wire.DecodeError) ](treetree: Wire.Value, Shape) == SquareSquare: fn(sparse named fields (side: Int)) -> Book.Serialization.Shape { side = 22: Int; };

  let messagemessage: String = match Wire.of_valueof_value: pure fn('target: Decode)(value: Wire.Value, target: Struct(Int)) -> Int, exn [ :decode(Wire.DecodeError) ](Wire.Value.Number("1.5""1.5": String), Int) {
    | _ -> "decoded""decoded": String
    | error :decode(failurefailure: Wire.DecodeError) -> failurefailure: Wire.DecodeError.messagemessage: String
  };
  expect ✓messagemessage: String == "expected Int""expected Int": String;

Formats only write numbers they can read back: infinities and NaN have no spelling, so encoding one fails instead of producing an unreadable document.

Finite Numbers

test✓
  let messagemessage: String = match Json.encodeencode: pure fn('a: Encode)(value: Float) -> Bytes, exn [ :encode(Wire.EncodeError) ](1.01.0: Float / 0.00.0: Float) {
    | _ -> "encoded""encoded": String
    | error :encode(failurefailure: Wire.EncodeError) -> failurefailure: Wire.EncodeError.messagemessage: String
  };
  expect ✓messagemessage: String == "non-finite Float `inf` has no Wire spelling""non-finite Float `inf` has no Wire spelling": String;

XML

XML uses the same Encode and Decode modules. Products and cases are elements, sequence items are <item>, map entries carry their key as an attribute, and None is a self-closing element. XML text is untyped, so it cannot decode a Wire.Value.

XML

test✓
  let bodybody: Bytes = must Xml.encodeencode: pure fn('a: Encode)(value: Book.Serialization.Signup) -> Bytes, exn [ :encode(Wire.EncodeError) ](signupsignup: Book.Serialization.Signup);
  expect ✓texttext: fn(body: Bytes) -> String(bodybody: Bytes) == "<Signup><email>ada@example.com</email><age>36</age><nickname/></Signup>""<Signup><email>ada@example.com</email><age>36</age><nickname/></Signup>": String;
  expect ✓must Xml.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Signup), ~max_depth: Int = 128) -> Book.Serialization.Signup, exn [ :decode(Wire.DecodeError) ](bodybody: Bytes, Signup) == signupsignup: Book.Serialization.Signup;

TOML

Toml.parse keeps TOML's offset date-times as Instant and local dates as LocalDate. TOML has no null, so an omitted field decodes as None and encoding leaves None out. Decode errors point at the source key.

type Backend = {
  name: String;
  port: Int;
  timeout: Option(Int);
}

type Deploy = {
  started: Instant;
  services: Vector(Backend);
}

TOML

test✓
  let deploydeploy: Book.Serialization.Deploy = must Toml.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Deploy), ~max_depth: Int = 128) -> Book.Serialization.Deploy, exn [ :decode(Wire.DecodeError) ](
    b"started = 2026-10-06T12:00:00Z\n\n[[services]]\nname = \"api\"\nport = 8_080\n"b"started = 2026-10-06T12:00:00Z\n\n[[services]]\nname = \"api\"\nport = 8_080\n": Bytes,
    Deploy,
  );
  expect ✓deploydeploy: Book.Serialization.Deploy.servicesservices: Vector(Book.Serialization.Backend) == [Backend { name = "api""api": String; port = 80808080: Int; timeout = None; }];
  expect ✓texttext: fn(body: Bytes) -> String(must Toml.encodeencode: pure fn('a: Encode)(value: Book.Serialization.Deploy) -> Bytes, exn [ :encode(Wire.EncodeError) ](deploydeploy: Book.Serialization.Deploy))
    == "started = 2026-10-06T12:00:00Z\n\n[[services]]\nname = \"api\"\nport = 8080\n""started = 2026-10-06T12:00:00Z\n\n[[services]]\nname = \"api\"\nport = 8080\n": String;

  let (pathpath: String, offsetoffset: Option(Int)) = match Toml.decodedecode: pure fn('target: Decode)(body: Bytes, target: Struct(Book.Serialization.Deploy), ~max_depth: Int = 128) -> Book.Serialization.Deploy, exn [ :decode(Wire.DecodeError) ](
    b"started = 2026-10-06T12:00:00Z\n[[services]]\nname = 1\nport = 1\n"b"started = 2026-10-06T12:00:00Z\n[[services]]\nname = 1\nport = 1\n": Bytes,
    Deploy,
  ) {
    | _ -> ("decoded""decoded": String, NoneNone: Option(Int))
    | error :decode(failurefailure: Wire.DecodeError) -> (Wire.Path.to_stringto_string: pure fn!(path: Vector(Wire.Path.Segment)) -> String(failurefailure: Wire.DecodeError.pathpath: Vector(Wire.Path.Segment)), failurefailure: Wire.DecodeError.offsetoffset: Option(Int))
  };
  expect ✓pathpath: String == "\$.services[0].name""\$.services[0].name": String;
  expect ✓offsetoffset: Option(Int) == SomeSome: fn(Int) -> Option(Int)(5151: Int);

TOML local date-times and local times have no Ship type yet, and inf and nan have no Wire.Value spelling, so parsing them fails.

TOML Limits

test✓
  let messagemessage: String = match Toml.parseparse: pure fn(body: Bytes, ~max_depth: Int = 128) -> Wire.Value, exn [ :parse(Toml.ParseError) ](b"at = 2026-10-06T12:00:00\n"b"at = 2026-10-06T12:00:00\n": Bytes) {
    | _ -> "parsed""parsed": String
    | error :parse(failurefailure: Toml.ParseError) -> failurefailure: Toml.ParseError.messagemessage: String
  };
  expect ✓messagemessage: String == "TOML local date-times have no Ship type; add an offset such as `Z`""TOML local date-times have no Ship type; add an offset such as `Z`": String;