# serde.zig [![Build](https://github.com/OrlovEvgeny/serde.zig/actions/workflows/ci.yml/badge.svg)](https://github.com/OrlovEvgeny/serde.zig/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/OrlovEvgeny/serde.zig?label=release)](https://github.com/OrlovEvgeny/serde.zig/releases/latest) [![Zig](https://img.shields.io/badge/zig-0.15.2%20%7C%200.16.0%20%7C%200.17.0%20%7C%200.18--dev-blue)](https://ziglang.org/download/) Serialization framework for Zig serde.zig uses Zig's comptime reflection (`@typeInfo`) to serialize and deserialize supported Zig types across JSON, MessagePack, Erlang ETF, TOML, YAML, XML, ZON, TOON, and CSV without macros, code generation, or runtime type information. ## Quick start Add the v1.2.3 dependency: ```sh zig fetch --save https://github.com/OrlovEvgeny/serde.zig/archive/refs/tags/v1.2.3.tar.gz ``` In `build.zig`, connect the public module to your executable: ```zig const serde_dep = b.dependency("serde", .{ .target = target, .optimize = optimize }); exe.root_module.addImport("serde", serde_dep.module("serde")); ``` This complete program serializes a value and releases both the output bytes and the managed parse result. For validation and error diagnostics, run the [managed JSON example](examples/managed_json/README.md) with `zig build example-managed-json`. ```zig const std = @import("std"); const serde = @import("serde"); const User = struct { name: []const u8, age: u32, email: ?[]const u8 = null }; pub fn main() !void { const allocator = std.heap.page_allocator; const bytes = try serde.json.toSlice(allocator, User{ .name = "Alice", .age = 30 }); defer allocator.free(bytes); var parsed = try serde.json.fromSliceManaged(User, allocator, bytes); defer parsed.deinit(); std.debug.print("{s}: {d}\n", .{ parsed.value.name, parsed.value.age }); } ``` Choose a guide: [application author](docs/application-author.md), [type or adapter author](docs/type-author.md), or [format author](docs/format-author.md). The [extension contract](docs/extension-contract.md) and [capability matrix](docs/capabilities.md) define what integrations can rely on. ## Installation To track development instead of a release: ```sh zig fetch --save git+https://github.com/OrlovEvgeny/serde.zig ``` serde.zig supports Zig 0.15.2, 0.16.0 and 0.17.0, and CI also tracks Zig master: | Zig version | Status | | --------------------- | ----------------------------------------------------------------- | | `0.17.0` | default toolchain, required in docs CI | | `0.16.0` | supported | | `0.15.2` | supported compatibility baseline | | `0.18-dev` / `master` | supported against current development snapshots and tracked in CI | ## Why serde.zig? You declare a supported struct and serialize it, with no macros or code generation. Comptime reflection picks the mapping; parsing and input validation happen at runtime. All nine formats share a generic core and similar convenience functions. Root types, writer arguments, and capabilities differ between formats, so check the capability matrix. Out-of-band schemas serialize the same type differently in different contexts without modifying the type. This helps with third-party types and API versioning. For JSON, `fromSliceBorrowed` returns string slices that point directly into the input buffer when no escape sequences are present. Strings are not copied, though containers and custom hooks can still allocate. Unsupported type operations and ambiguous field names can be rejected at compile time. Missing input fields and malformed input are runtime errors. Structural backend checks are opt-in. ## Table of contents - [Quick start](#quick-start) - [Installation](#installation) - [Why serde.zig?](#why-serdezig) - [Formats](#formats) - [Supported types](#supported-types) - [Examples](#examples) - [Nested structs](#nested-structs) - [Managed parsing and memory ownership](#managed-parsing-and-memory-ownership) - [Zero-copy deserialization](#zero-copy-deserialization) - [Pretty-printed output](#pretty-printed-output) - [Tagged unions](#tagged-unions) - [Enums](#enums) - [Maps](#maps) - [CSV](#csv) - [TOML](#toml) - [YAML](#yaml) - [XML](#xml) - [ZON](#zon) - [TOON](#toon) - [Erlang ETF / OTP 29](#erlang-etf--otp-29) - [Serde options](#serde-options) - [Field renaming](#field-renaming) - [Asymmetric renaming](#asymmetric-renaming) - [Field aliases](#field-aliases) - [Enum and union variant renaming](#enum-and-union-variant-renaming) - [Skip fields](#skip-fields) - [Default values](#default-values) - [Deny unknown fields](#deny-unknown-fields) - [Flatten nested structs](#flatten-nested-structs) - [Union tagging styles](#union-tagging-styles) - [Enum representation](#enum-representation) - [Per-field custom serialization](#per-field-custom-serialization) - [Out-of-band schema](#out-of-band-schema) - [Out-of-band type overrides](#out-of-band-type-overrides) - [Custom serialization](#custom-serialization) - [JSON diagnostics](#json-diagnostics) - [Error handling](#error-handling) - [Format options](#format-options) - [Migration](#migration) - [Performance](#performance) - [Tests](#tests) - [License](#license) ## Formats | Format | Module | Serialize | Deserialize | | ------------------- | --------------- | --------- | ----------- | | JSON | `serde.json` | + | + | | MessagePack | `serde.msgpack` | + | + | | Erlang ETF / OTP 29 | `serde.etf` | + | + | | TOML | `serde.toml` | + | + | | YAML | `serde.yaml` | + | + | | XML | `serde.xml` | + | + | | ZON | `serde.zon` | + | + | | TOON | `serde.toon` | + | + | | CSV | `serde.csv` | + | + | The snippet below shows the common convenience API with JSON. Some formats take extra writer arguments or restrict root types: ```zig // Serialization const bytes = try serde.json.toSlice(allocator, value); try serde.json.toWriter(&writer, value); // Deserialization const val = try serde.json.fromSlice(T, allocator, bytes); const val = try serde.json.fromReader(T, allocator, &reader); ``` ## Supported types - `bool`, `i8`..`i128`, `u8`..`u128`, `f16`..`f128` - `[]const u8`, `[]u8`, `[:0]const u8` (strings) - `?T` (optionals, serialized as value or null) - `[N]T` (fixed-length arrays) - `[]T`, `[]const T` (slices) - Structs with named fields, nested arbitrarily - Tuples (`struct { i32, bool }`, serialized as arrays) - Enums (as string name or integer) - Tagged unions (`union(enum)`, four tagging styles) - `*T`, `*const T` (pointers, followed transparently) - `std.StringHashMap(V)` (maps) - `void` (serialized as null) ## Examples The [runnable examples](examples/README.md) cover order processing, configuration validation, CSV reports, log streaming, and binary telemetry. Each has a README with its run command, inputs, expected output, and memory ownership details. ### Nested structs ```zig const Address = struct { street: []const u8, city: []const u8, zip: []const u8, }; const Person = struct { name: []const u8, age: u32, address: Address, tags: []const []const u8, }; const person = Person{ .name = "Bob", .age = 25, .address = .{ .street = "123 Main St", .city = "Springfield", .zip = "62704" }, .tags = &.{ "admin", "active" }, }; const json = try serde.json.toSlice(allocator, person); const msgpack = try serde.msgpack.toSlice(allocator, person); const yaml = try serde.yaml.toSlice(allocator, person); const xml = try serde.xml.toSlice(allocator, person); const toon = try serde.toon.toSlice(allocator, person); ``` ### Managed parsing and memory ownership Every format provides `fromSliceManaged(T, allocator, input)` and `fromSliceManagedSchema(T, allocator, input, schema)`. They return `serde.Parsed(T)`: ```zig var parsed = try serde.json.fromSliceManaged(User, allocator, json_bytes); defer parsed.deinit(); useUser(parsed.value); ``` The result owns a separate arena with a stable address. Custom adapters allocate into the same arena through the allocator they are given. You can move the result, but keep one owner, call `deinit` exactly once, and do not free its fields separately. Allocated output is independent of the input, while static Zig and schema defaults stay static. The API does not close resources such as files opened by custom hooks. `fromSlice` allocates strings and containers with the caller's allocator. An arena is convenient for batch parsing: ```zig var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const user = try serde.json.fromSlice(User, arena.allocator(), json_bytes); ``` For ordinary derived values, `serde.core.freeAllocated(T, value, allocator)` recursively frees owned output and preserves defaults; use `freeAllocatedSchema` when parsing with an external schema. Prefer managed parsing or your own arena for custom types whose ownership cannot be inferred from their fields. A nonallocating field `with.deserialize` may retain its wire value; an allocating `with.deserializeAlloc` must produce an independent result. Managed parsing keeps each format's root restrictions and writer signatures: CSV reads rows into a slice of structs, TOML reads a struct or table, and XML adds a root element. `fromReader` variants own their temporary input buffers and release them after conversion. ### Zero-copy deserialization When strings in the JSON input contain no escape sequences, `fromSliceBorrowed` returns slices pointing directly into the input buffer: ```zig const input = "{\"name\":\"alice\",\"id\":1}"; const msg = try serde.json.fromSliceBorrowed(Msg, allocator, input); // msg.name points into input, input must outlive msg ``` The input must outlive the result. An escaped string fails with `InvalidEscape` instead of being copied into a new allocation, and that holds inside arrays and nested containers too. Use a separate arena for container metadata, and do not pass borrowed results to `freeAllocated`. ### Pretty-printed output ```zig const pretty = try serde.json.toSliceWith(allocator, value, .{ .pretty = true, .indent = 2 }); // { // "name": "Alice", // "age": 30 // } ``` ### Tagged unions ```zig const Command = union(enum) { ping: void, execute: struct { query: []const u8 }, quit: void, }; const cmd = Command{ .execute = .{ .query = "SELECT 1" } }; const bytes = try serde.json.toSlice(allocator, cmd); // => {"execute":{"query":"SELECT 1"}} ``` ### Enums ```zig const Color = enum { red, green, blue }; const bytes = try serde.json.toSlice(allocator, Color.blue); // => "blue" const color = try serde.json.fromSlice(Color, allocator, bytes); // => Color.blue ``` ### Maps ```zig var map = std.StringHashMap(i32).init(allocator); defer map.deinit(); try map.put("a", 1); try map.put("b", 2); const bytes = try serde.json.toSlice(allocator, map); // => {"a":1,"b":2} ``` ### CSV ```zig const Record = struct { name: []const u8, age: u32, active: bool, }; const records: []const Record = &.{ .{ .name = "Alice", .age = 30, .active = true }, .{ .name = "Bob", .age = 25, .active = false }, }; const csv_bytes = try serde.csv.toSlice(allocator, records); // name,age,active // Alice,30,true // Bob,25,false ``` ### TOML ```zig const Config = struct { title: []const u8, port: u16 = 8080, database: struct { host: []const u8, name: []const u8, }, }; const cfg = try serde.toml.fromSlice(Config, arena.allocator(), \\title = "myapp" \\port = 3000 \\ \\[database] \\host = "localhost" \\name = "mydb" ); ``` ### YAML ```zig const Server = struct { host: []const u8, port: u16, debug: bool, }; const yaml_input = \\host: localhost \\port: 8080 \\debug: true ; const server = try serde.yaml.fromSlice(Server, arena.allocator(), yaml_input); const yaml_bytes = try serde.yaml.toSlice(allocator, server); // host: localhost // port: 8080 // debug: true ``` ### XML ```zig const User = struct { id: u64, name: []const u8, role: []const u8, pub const serde = .{ .xml_attribute = .{.id}, .xml_root = "user", }; }; const xml_bytes = try serde.xml.toSlice(allocator, User{ .id = 42, .name = "Alice", .role = "admin", }); // // Aliceadmin const user = try serde.xml.fromSlice(User, arena.allocator(), xml_bytes); ``` Fields listed in `xml_attribute` are serialized as XML attributes on the root element. All other fields become child elements. ### ZON `serde.zon` writes valid `.zon` files: ```zig const bytes = try serde.zon.toSlice(allocator, Config{ .title = "myapp", .port = 3000, .database = .{ .host = "localhost", .name = "mydb" }, }); // .{ // .title = "myapp", // .port = 3000, // .database = .{ // .host = "localhost", // .name = "mydb", // }, // } ``` ### TOON `serde.toon` writes compact TOON documents for the JSON data model: ```zig const bytes = try serde.toon.toSlice(allocator, Config{ .title = "myapp", .port = 3000, .database = .{ .host = "localhost", .name = "mydb" }, }); // title: myapp // port: 3000 // database: // host: localhost // name: mydb ``` ## Erlang ETF / OTP 29 `serde.etf` supports the current [External Term Format](https://www.erlang.org/doc/apps/erts/erl_ext_dist.html) and the connected-message portion of the [OTP 29 Distribution Protocol](https://www.erlang.org/docs/29/apps/erts/erl_dist_protocol.html), including legacy input tags, arbitrary bignums, improper lists, bit-binaries, maps with term keys, process identities, funs, exports, opaque top-level `LOCAL_EXT`, and OTP 29 native `RECORD_EXT`. It has two APIs: - Typed serialization for ordinary Zig values, with the same rename, skip, flatten, union, schema, and custom-adapter rules as the other formats. - An owning `Term` model for exact Erlang semantics, with `deinit`, `clone`, and semantic `eql`. ### Typed mapping | Zig value | Canonical ETF output | |-----------|----------------------| | `bool` | atoms `true` / `false` | | `null`, `void`, empty optional | atom `null` | | byte strings | `BINARY_EXT` | | integers | smallest integer or bignum tag | | floats | finite `NEW_FLOAT_EXT` | | arrays, slices, tuples | proper ETF lists | | structs and maps | `MAP_EXT` | | field, enum, union names | binary | The default decoder accepts common Erlang and Elixir representations: `null`, `nil`, `undefined`, and `NIL_EXT` can become Zig null; binary, `STRING_EXT`, and byte-lists can become strings; names may be atoms or binaries; and sequences may be proper lists or tuples. Set `.strict = true` to accept only the canonical profile. ```zig const wire = try serde.etf.toSliceWith(allocator, config, .{ .compression = .{ .threshold = 1024 }, .zlib_level = 6, }); defer allocator.free(wire); const decoded = try serde.etf.fromSliceWith(Config, allocator, wire, .{ .strict = true, }); ``` ### Exact `Term` API All decoded slices and nested terms are owned by the allocator passed to `decodeTerm`. ```zig var term = try serde.etf.decodeTerm(allocator, bytes, .{}); defer term.deinit(allocator); var copy = try term.clone(allocator); defer copy.deinit(allocator); const canonical = try serde.etf.encodeTerm(allocator, term, .{}); defer allocator.free(canonical); ``` Legacy float/atom/pid/port/reference and compact number/tuple tags normalize to modern semantic values. Latin-1 atoms normalize to UTF-8. `legacy_fun` requires `.allow_legacy = true` when writing, and opaque `local` requires `.allow_local = true`. NaN and infinity are rejected. ### Compression and limits The decoder unpacks `COMPRESSED_EXT` automatically and verifies its zlib checksum. Encoding supports `.never` (default), `.always`, and `.threshold`; `zlib_level` 0 stores the payload uncompressed while 1-9 trade speed for ratio. Defensive defaults apply to both dynamic and typed APIs: - 10 MiB input/output and uncompressed term size - depth 256 - 1,000,000 collection elements - 1 MiB bignum magnitude All limits are configurable through `EncodeOptions` and `DecodeOptions`. Length arithmetic is checked before allocation. ### Distribution codec `serde.etf.distribution` provides negotiated capability flags, independent 2048-entry send/receive atom caches, 4-byte framing, ticks, typed control operation variants, and OTP fragmentation/reassembly. ```zig const dist = serde.etf.distribution; const caps = dist.CapabilityFlags{ .bits = dist.CapabilityFlags.dist_hdr_atom_cache | dist.CapabilityFlags.utf8_atoms | dist.CapabilityFlags.fragments, }; var decoder = dist.Decoder.init(allocator, caps, .{}); defer decoder.deinit(); try decoder.feed(network_chunk); // chunks may split headers or frames while (try decoder.next()) |message_value| { var message = message_value; defer message.deinit(allocator); // message is .tick or .data.{ control, payload } } ``` The encoder checks negotiated flags before spawn, payload-exit, unlink-id, alias, alternate-action, fragmentation, or native-record use. The decoder limits frames/reassembled messages to 10 MiB, concurrent fragment sequences to 64, and fragments per sequence to 1024 by default. Legacy pass-through type 112 is accepted. The module takes over after the distribution handshake. EPMD, node handshakes, cookies, TCP/TLS, reconnection, and a ready-made Erlang node client are intentionally out of scope. ## Serde options Customize serialization behavior by declaring `pub const serde` on your types. All options are resolved at comptime. ### Field renaming ```zig const User = struct { user_id: u64, first_name: []const u8, last_name: []const u8, pub const serde = .{ .rename = .{ .user_id = "id" }, .rename_all = serde.NamingConvention.camel_case, }; }; // Serializes as: {"id":1,"firstName":"Alice","lastName":"Smith"} ``` Available conventions: `.camel_case`, `.snake_case`, `.pascal_case`, `.kebab_case`, `.SCREAMING_SNAKE_CASE`. ### Asymmetric renaming Serialization and deserialization can use different names. This helps while an API is changing or a rolling upgrade is in progress, and with systems that use one naming convention for input and another for output. ```zig const User = struct { user_id: u64, first_name: []const u8, pub const serde = .{ // Serialize as "id", but accept "user_id" on input .rename_serialize = .{ .user_id = "id" }, // Different case conventions per direction .rename_all_serialize = serde.NamingConvention.camel_case, .rename_all_deserialize = serde.NamingConvention.snake_case, }; }; // Serializes as: {"id":42,"firstName":"Alice"} // Deserializes from: {"user_id":42,"first_name":"Alice"} ``` Direction-specific options (`rename_serialize`, `rename_deserialize`, `rename_all_serialize`, `rename_all_deserialize`) take priority over their symmetric counterparts (`rename`, `rename_all`). ### Field aliases Aliases let one field accept several input names during deserialization. They do not affect serialization output. Use them for backward compatibility when field names change between API versions. ```zig const Config = struct { endpoint: []const u8, pub const serde = .{ .alias = .{ .endpoint = &.{ "url", "uri", "addr" } }, }; }; // All of these deserialize into .endpoint: // {"endpoint": "..."}, {"url": "..."}, {"uri": "..."}, {"addr": "..."} // Serializes as: {"endpoint": "..."} ``` Aliases combine with `rename` and `rename_all`: ```zig const User = struct { user_id: u64, pub const serde = .{ .rename = .{ .user_id = "id" }, .alias = .{ .user_id = &.{ "user_id", "userId", "uid" } }, }; }; // Primary name: "id" (from rename) // Also accepts: "user_id", "userId", "uid" (from alias) // Serializes as: {"id": 42} ``` ### Enum and union variant renaming Rename and alias options also apply to enum values and union variant tags: ```zig const Status = enum { active, inactive, in_review, pub const serde = .{ .rename_all_serialize = serde.NamingConvention.SCREAMING_SNAKE_CASE, .rename_all_deserialize = serde.NamingConvention.SCREAMING_SNAKE_CASE, .alias = .{ .in_review = &.{ "in_review", "pending_review" } }, }; }; // Serializes as: "IN_REVIEW" // Accepts: "IN_REVIEW", "in_review", "pending_review" ``` ```zig const Command = union(enum) { ping: void, execute: struct { query: []const u8 }, pub const serde = .{ .tag = serde.UnionTag.internal, .tag_field = "type", .rename = .{ .execute = "exec" }, .alias = .{ .execute = &.{ "execute", "run" } }, }; }; // Serializes as: {"type":"exec","query":"SELECT 1"} // Accepts: "exec", "execute", "run" as variant tag values ``` ### Skip fields ```zig const Secret = struct { name: []const u8, token: []const u8, email: ?[]const u8, tags: []const []const u8, pub const serde = .{ .skip = .{ .token = serde.SkipMode.always, .email = serde.SkipMode.@"null", .tags = serde.SkipMode.empty, }, }; }; ``` Directional skip maps can be used in `pub const serde` or an external schema: ```zig pub const serde = .{ .skip_serializing = .{ .secret = true }, .skip_deserializing = .{ .computed = true }, }; ``` `skip.always` skips both directions. A schema entry, including explicit `false`, overrides the corresponding in-type setting. Skipped input fields still need a Zig or schema default, or an optional type, to construct the result. ### Default values When a field is missing from the input, deserialization uses the struct's Zig default value: ```zig const Config = struct { name: []const u8, retries: i32 = 3, timeout: i32 = 30, }; const cfg = try serde.json.fromSlice(Config, allocator, "{\"name\":\"app\"}"); // cfg.retries == 3, cfg.timeout == 30 ``` Struct input rejects repeated fields with `DuplicateField`, including a primary name followed by its alias. Maps keep the last value. Recursive `flatten` uses the nested fields' names, skips, defaults and required-field checks; ambiguous names or aliases across fields are compile errors. Internal and adjacent union tags may appear before or after their payload. `Value.fromAny` and `Value.toType` use the same core field and union rules as format conversion. ### Deny unknown fields ```zig const Strict = struct { x: i32, pub const serde = .{ .deny_unknown_fields = true, }; }; // Returns error.UnknownField if input contains unexpected keys ``` ### Flatten nested structs ```zig const Metadata = struct { created_by: []const u8, version: i32 = 1, }; const User = struct { name: []const u8, meta: Metadata, pub const serde = .{ .flatten = &[_][]const u8{"meta"}, }; }; // Serializes as: {"name":"Alice","created_by":"admin","version":2} // instead of: {"name":"Alice","meta":{"created_by":"admin","version":2}} ``` ### Union tagging styles ```zig const Command = union(enum) { ping: void, execute: struct { query: []const u8 }, pub const serde = .{ // .external (default): {"execute":{"query":"SELECT 1"}} // .internal: {"type":"execute","query":"SELECT 1"} // .adjacent: {"type":"execute","content":{"query":"SELECT 1"}} // .untagged: {"query":"SELECT 1"} .tag = serde.UnionTag.internal, .tag_field = "type", }; }; ``` ### Enum representation ```zig const Status = enum(u8) { active = 0, inactive = 1, pending = 2, pub const serde = .{ .enum_repr = serde.EnumRepr.integer, // serialize as 0, 1, 2 }; }; // Default is .string: "active", "inactive", "pending" ``` ### Per-field custom serialization ```zig const Event = struct { name: []const u8, created_at: i64, pub const serde = .{ .with = .{ .created_at = serde.helpers.UnixTimestampMs, }, }; }; ``` Built-in helpers: `serde.helpers.UnixTimestamp`, `serde.helpers.UnixTimestampMs`, `serde.helpers.Base64`. ## Out-of-band schema An external schema changes how a type serializes without modifying the type. Use it for third-party types you don't control, or when the same type needs different wire representations in different contexts. ```zig const Point = struct { x: f64, y: f64, z: f64 }; // External schema: rename fields, skip z const schema = .{ .rename = .{ .x = "X", .y = "Y" }, .skip = .{ .z = serde.SkipMode.always }, }; const point = Point{ .x = 1.0, .y = 2.0, .z = 3.0 }; // Serialize with schema const bytes = try serde.json.toSliceSchema(allocator, point, schema); // => {"X":1.0e0,"Y":2.0e0} // Deserialize with schema const p = try serde.json.fromSliceSchema(Point, allocator, bytes, schema); // p.x == 1.0, p.y == 2.0, p.z == 0.0 (default) ``` The same type can be serialized differently with different schemas: ```zig const full_schema = .{ .rename_all = serde.NamingConvention.SCREAMING_SNAKE_CASE, }; const compact_schema = .{ .rename = .{ .x = "a", .y = "b" }, .skip = .{ .z = serde.SkipMode.always }, }; const full = try serde.json.toSliceSchema(allocator, point, full_schema); // => {"X":1.0e0,"Y":2.0e0,"Z":3.0e0} const compact = try serde.json.toSliceSchema(allocator, point, compact_schema); // => {"a":1.0e0,"b":2.0e0} ``` A schema accepts the same options as `pub const serde`: `rename`, `rename_all`, `rename_serialize`, `rename_deserialize`, `rename_all_serialize`, `rename_all_deserialize`, `alias`, `skip`, `skip_serializing`, `skip_deserializing`, `default`, `with`, `deny_unknown_fields`, `flatten`, `tag`, `tag_field`, `content_field`, `enum_repr`. When a type declares `pub const serde` and you also pass an external schema, the external schema takes priority. Every format module has the `*Schema` variants: `toSliceSchema`, `toWriterSchema`, `fromSliceSchema`, `fromReaderSchema`, and so on. ## Out-of-band type overrides A type override changes how a specific type is serialized and deserialized at the call site, without modifying the type. Use it for types you don't own, such as `std.ArrayList` or structs from another library, or for a one-off representation. Pass a comptime map of `{Type, Adapter}` pairs to the `*WithMap` functions: ```zig const std = @import("std"); const serde = @import("serde"); // A type from a library you don't control const Timestamp = struct { seconds: i64, nanos: u32, }; // Define how to serialize/deserialize it const TimestampAdapter = struct { pub fn serialize(value: Timestamp, s: anytype) @TypeOf(s.*).Error!void { // Serialize as a single float: seconds.nanos const ms: f64 = @as(f64, @floatFromInt(value.seconds)) + @as(f64, @floatFromInt(value.nanos)) / 1_000_000_000.0; try s.serializeFloat(ms); } pub fn deserialize( comptime _: type, _: std.mem.Allocator, d: anytype, ) @TypeOf(d.*).Error!Timestamp { const val = try d.deserializeFloat(f64); const secs: i64 = @intFromFloat(val); const nanos: u32 = @intFromFloat((val - @as(f64, @floatFromInt(secs))) * 1_000_000_000.0); return .{ .seconds = secs, .nanos = nanos }; } }; // Build the map and use it const map = .{ .{ Timestamp, TimestampAdapter } }; const Event = struct { name: []const u8, at: Timestamp, }; const event = Event{ .name = "deploy", .at = .{ .seconds = 1700000000, .nanos = 500000000 }, }; const bytes = try serde.json.toSliceWithMap(allocator, event, map); // => {"name":"deploy","at":1700000000.5} var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const result = try serde.json.fromSliceWithMap(Event, arena.allocator(), bytes, map); // result.at.seconds == 1700000000, result.at.nanos == 500000000 ``` ### How it works The map is a comptime tuple where each entry is `.{ TargetType, AdapterModule }`. The adapter module must provide: - `fn serialize(value: T, s: anytype) !void` writes the value to the serializer. - `fn deserialize(comptime T: type, allocator: Allocator, d: anytype) !T` reads the value from the deserializer. When serde encounters a type that matches a map entry, it calls the adapter instead of the default comptime-derived serialization. The check happens at every level: top-level values, struct fields, array elements, optional contents, and union payloads. ### Available functions JSON has these map-aware convenience functions. TOON and ETF have their own format-specific map variants, and the other formats use the core functions shown further down: ```zig // Serialize const bytes = try serde.json.toSliceWithMap(allocator, value, map); try serde.json.toWriterWithMap(&writer, value, map); // Deserialize const val = try serde.json.fromSliceWithMap(T, allocator, bytes, map); const val = try serde.json.fromSliceBorrowedWithMap(T, allocator, bytes, map); const val = try serde.json.fromReaderWithMap(T, allocator, &reader, map); ``` For more control, use the core functions directly: ```zig try serde.serializeWith(T, value, &serializer, map); const val = try serde.deserializeWith(T, allocator, &deserializer, map); ``` ### Precedence When multiple customization mechanisms apply to the same type: 1. `zerdeSerialize` / `zerdeDeserialize` on the type itself (highest priority) 2. Out-of-band map entry 3. Default comptime-derived behavior (lowest priority) ### Example: `std.ArrayList(u8)` as string ```zig const ArrayListAdapter = struct { pub fn serialize(value: std.ArrayList(u8), s: anytype) @TypeOf(s.*).Error!void { try s.serializeString(value.items); } pub fn deserialize( comptime _: type, allocator: std.mem.Allocator, d: anytype, ) @TypeOf(d.*).Error!std.ArrayList(u8) { const str = try d.deserializeString(allocator); defer serde.core.releaseString(d, allocator, str); var list = std.ArrayList(u8).empty; errdefer list.deinit(allocator); try list.appendSlice(allocator, str); return list; } }; const map = .{ .{ std.ArrayList(u8), ArrayListAdapter } }; const Response = struct { status: u16, body: std.ArrayList(u8), }; const resp = Response{ .status = 200, .body = blk: { var b = std.ArrayList(u8).empty; try b.appendSlice(allocator, "OK"); break :blk b; }, }; const bytes = try serde.json.toSliceWithMap(allocator, resp, map); // => {"status":200,"body":"OK"} // Without the map, body would serialize as {"items":"OK","capacity":N,"items.len":2} ``` ## Custom serialization For full control, declare `zerdeSerialize` and/or `zerdeDeserialize` on your type: ```zig const StringWrappedU64 = struct { inner: u64, pub fn zerdeSerialize(self: @This(), serializer: anytype) !void { var buf: [20]u8 = undefined; const s = std.fmt.bufPrint(&buf, "{d}", .{self.inner}) catch unreachable; try serializer.serializeString(s); } pub fn zerdeDeserialize( comptime _: type, allocator: std.mem.Allocator, deserializer: anytype, ) @TypeOf(deserializer.*).Error!@This() { const str = try deserializer.deserializeString(allocator); defer serde.core.releaseString(deserializer, allocator, str); return .{ .inner = std.fmt.parseInt(u64, str, 10) catch return deserializer.raiseError(error.InvalidNumber) }; } }; const bytes = try serde.json.toSlice(allocator, StringWrappedU64{ .inner = 12345 }); // => "12345" ``` ## JSON diagnostics Use `json.fromSliceManagedWithDiagnostics(T, allocator, input, options, &diagnostics)` with `var diagnostics = serde.json.Diagnostics.init(&path_buffer)` to keep the original error, JSON pointer, byte offset, line, and byte column. The path uses only the supplied buffer; a short buffer sets `path_truncated` without changing the error. See the [application guide](docs/application-author.md#json-diagnostics). ## Error handling Deserialization returns specific errors: - `error.UnexpectedToken`: malformed input - `error.UnexpectedEof`: input ended prematurely - `error.MissingField`: required struct field absent - `error.UnknownField`: unexpected field (with `deny_unknown_fields`) - `error.InvalidNumber`: number parse failure or overflow - `error.InvalidUnicode`: malformed unicode escape (e.g. lone surrogate) - `error.InvalidControlCharacter`: unescaped control char in JSON string - `error.MaxDepthExceeded`: JSON nesting deeper than configured limit - `error.WrongType`: input type doesn't match target type - `error.DuplicateField`: same field appears twice - `error.FieldCountMismatch`: CSV row has fewer fields than headers - `error.MalformedXml`: structurally invalid XML (e.g. `--` inside comment) - `error.InvalidYaml`: structurally invalid YAML ```zig const result = serde.json.fromSlice(Config, allocator, input) catch |err| switch (err) { error.MissingField => std.debug.print("missing required field\n", .{}), error.UnexpectedEof => std.debug.print("truncated input\n", .{}), else => return err, }; ``` ## Format options Each format has a `fromSliceWith` entry point that takes format-specific deserialize options. JSON also takes serializer options through `toSliceWith`. ### JSON (`serde.json.DeserializeOptions`) ```zig const val = try serde.json.fromSliceWith(Config, allocator, input, .{ .lenient_null_to_zero = false, // true: null -> 0 for int/float .allow_unescaped_control_chars = false, // true: accept raw 0x00..0x1f in strings .max_depth = 256, // raise for very nested input }); ``` The defaults reject input that violates RFC 8259. Set `lenient_null_to_zero` to read JSON `null` as `0` for non-optional numeric fields. ### YAML (`serde.yaml.DeserializeOptions`) ```zig const val = try serde.yaml.fromSliceWith(Config, allocator, input, .{ .yaml_11_booleans = false, // true: yes/no/on/off recognized as booleans .strict_indent = false, // true: error on tab in indentation columns }); ``` The default follows YAML 1.2. Enabling `yaml_11_booleans` brings the "Norway problem", where `country: NO` parses as `false`. ### CSV (`Dialect.strict_field_count`) ```zig const dialect = serde.csv.Dialect{ .strict_field_count = true }; const rows = try serde.csv.fromSliceWith([]const Row, allocator, input, dialect); ``` `strict_field_count` defaults to true, so a row with fewer fields than the header fails with `error.FieldCountMismatch`. Set it to false to silently fill missing trailing fields with empty values. ### JSON serializer (`serde.json.Options.escape_js_unsafe`) ```zig const bytes = try serde.json.toSliceWith(allocator, value, .{ .escape_js_unsafe = true }); ``` When true, U+2028 and U+2029 are written as `
` and `
`. Both characters are valid in JSON, but JavaScript before ES2019 does not allow them in string literals. Turn this on when you embed the output in an HTML `