Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

comptime-serde is a compile-time serialization/deserialization library for Zig.

Define your struct once, automatically serialize/deserialize across JSON, TOML, YAML, and Protobuf — zero runtime overhead, all type dispatch happens at comptime via @typeInfo.

comptime-serde architecture

Features

  • Zero-cost abstractions: All serialization/deserialization logic is generated at compile time. No runtime reflection, no vtables, no allocations beyond what you explicitly request.
  • Multi-format support: JSON, TOML, YAML, and Protobuf (WIP) out of the box, with a uniform API across all formats.
  • Ergonomic field options: Rename fields, skip fields, accept aliases, and omit nulls — all configured via a single serde_fields declaration on your type.
  • Arena-backed deserialization: Deserialized values are returned in a Parsed(T) wrapper with an arena allocator. Call deinit() to free everything at once.

API Reference

Auto-generated API docs for every public type and function are available at apidocs.

Requirements

  • Zig 0.16.0 or later

Quick Example

const std = @import("std");
const serde = @import("comptime_serde");

const User = struct {
    name: []const u8,
    age: u32,
    email: ?[]const u8 = null,

    pub const serde_fields = .{
        .name = .{
            .json = .{
                .serialize = .{ .rename = "userName" },
                .deserialize = .{ .rename = "userName", .alias = &.{"username"} },
            },
        },
    };
};

pub fn main() !void {
    // Serialize
    var buf: [256]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buf);
    const json_serde = serde.Serde(.json, User);
    try json_serde.serialize(&writer, .{ .name = "alice", .age = 30 });
    // Output: {"userName":"alice","age":30}

    // Deserialize
    var result = try json_serde.deserialize(std.heap.page_allocator,
        \\{"userName":"bob","age":25}
    );
    defer result.deinit();
    // result.value.name == "bob"
}

Getting Started

Installation

Add comptime-serde as a dependency in your build.zig.zon:

# Latest version
zig fetch --save git+https://github.com/jiacai2050/comptime-serde.git
# Tagged version
zig fetch --save git+https://github.com/jiacai2050/comptime-serde.git#v0.2.0

Then in your build.zig:

const serde_dep = b.dependency("comptime_serde", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("comptime_serde", serde_dep.module("comptime_serde"));

Core API

The public API lives in the root module:

const serde = @import("comptime_serde");

Serde(format, T)

Returns a comptime-generated struct with serialize and deserialize methods for type T in the given format.

const json = serde.Serde(.json, MyStruct);
const toml = serde.Serde(.toml, MyStruct);
const yaml = serde.Serde(.yaml, MyStruct);

Parsed(T)

Wraps a deserialized value with its backing arena allocator. Always call deinit() when done.

var result = try json.deserialize(allocator, input);
defer result.deinit();
// use result.value

Format

Enum of supported formats: .json, .toml, .yaml, .protobuf.

Supported Types

comptime-serde supports all common Zig types:

Zig TypeJSONTOMLYAML
booltrue/falsetrue/falsetrue/false
u8u64, i8i64numberintegerinteger
f32, f64numberfloatfloat
[]const u8stringstringstring
?Tnull or valueomitted or valuenull or value
[N]Tarrayarraysequence
[]Tarrayarray of tablessequence
struct { ... }objecttablemapping
enum { ... }stringstringstring

Serialization

Basic Usage

Every Serde(format, T) instance exposes a serialize method:

const json = serde.Serde(.json, MyStruct);

var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
try json.serialize(&writer, my_value);
const output = writer.buffered(); // the serialized bytes

The writer is a std.Io.Writer, which is the standard Zig I/O interface. You can use fixed buffers, buffered writers, or any other writer implementation.

Struct Serialization

Struct fields are serialized in declaration order:

const Point = struct {
    x: i32,
    y: i32,
};

// JSON: {"x":1,"y":2}
// TOML: x = 1\ny = 2
// YAML: x: 1\ny: 2

Optional Fields

Optional fields with null values are included by default:

const Data = struct {
    name: []const u8,
    note: ?[]const u8 = null,
};

// JSON: {"name":"alice","note":null}

Use omit_null to suppress null values from output. See Field Options.

Enum Serialization

Enums are serialized as their tag name string:

const Color = enum { red, green, blue };
// "red", "green", "blue"

Nested Types

Structs, arrays, and slices can be nested arbitrarily:

const Config = struct {
    name: []const u8,
    tags: []const []const u8,
    metadata: struct {
        version: u32,
        debug: bool,
    },
};

Deserialization

Basic Usage

Every Serde(format, T) instance exposes a deserialize method:

const json = serde.Serde(.json, MyStruct);

var result = try json.deserialize(allocator, input_bytes);
defer result.deinit();

// result.value is of type MyStruct

The returned Parsed(T) owns an arena allocator. All strings and slices in the deserialized value point into this arena. Call deinit() to free everything at once.

Error Handling

Deserialization can fail with:

  • error.MissingField — a required field is absent from the input
  • error.DuplicateField — the same key appears twice (JSON)
  • error.UnexpectedToken — malformed input
  • error.Overflow — numeric value out of range
  • Format-specific parse errors

Default Values

Fields with default values are optional in the input:

const Config = struct {
    host: []const u8 = "localhost",
    port: u16 = 8080,
    debug: bool = false,
};

// Input: {"port":9090}
// Result: host="localhost", port=9090, debug=false

Optional Fields

Optional fields default to null when absent:

const User = struct {
    name: []const u8,
    bio: ?[]const u8 = null,
};

// Input: {"name":"alice"}
// Result: name="alice", bio=null

Unknown Keys

Unknown keys in the input are silently ignored. This allows forward-compatible deserialization — new fields added to the struct won’t break parsing of old input.

Nested Types

Nested structs, arrays, and slices are deserialized recursively, following the same rules at each level.

Field Options

Field options control how individual struct fields are serialized and deserialized. Configure them via the serde_fields declaration on your type.

Configuration Structure

Options are organized as: field name → format → direction → option.

const MyStruct = struct {
    user_name: []const u8,

    pub const serde_fields = .{
        .user_name = .{
            .json = .{
                .serialize = .{ /* serialize options */ },
                .deserialize = .{ /* deserialize options */ },
            },
        },
    };
};

Each format (.json, .toml, .yaml) can have independent configuration. Options not specified default to no-op.

Serialize Options

OptionTypeDefaultDescription
rename?[]const u8nullOutput this key instead of the Zig field name
skipboolfalseOmit this field from output entirely
omit_nullboolfalseOmit this field when its value is null (optional fields only)

rename

Change the output key name:

pub const serde_fields = .{
    .user_name = .{
        .json = .{
            .serialize = .{ .rename = "userName" },
        },
    },
};
// Zig: .user_name = "alice"  →  JSON: {"userName":"alice"}

skip

Exclude a field from serialized output:

pub const serde_fields = .{
    .password = .{
        .json = .{
            .serialize = .{ .skip = true },
        },
    },
};

omit_null

Suppress optional fields when null:

const Data = struct {
    id: u32,
    note: ?[]const u8 = null,

    pub const serde_fields = .{
        .note = .{
            .json = .{
                .serialize = .{ .omit_null = true },
            },
        },
    };
};
// note=null  →  {"id":1}       (note omitted)
// note="hi"  →  {"id":1,"note":"hi"}

Deserialize Options

OptionTypeDefaultDescription
rename?[]const u8nullAccept this key instead of the Zig field name
skipboolfalseDon’t read this field from input (uses default/null)
alias[]const []const u8&.{}Accept these keys in addition to the effective name

rename

Accept an alternative key name. When set, the original field name is no longer accepted — only the rename and any aliases match.

pub const serde_fields = .{
    .user_name = .{
        .json = .{
            .deserialize = .{ .rename = "userName" },
        },
    },
};
// {"userName":"alice"}  →  user_name = "alice"   ✓
// {"user_name":"alice"} →  error.MissingField     ✗ (original name rejected)

alias

Accept additional key names. Aliases are matched in addition to the rename (or field name when no rename is set).

pub const serde_fields = .{
    .user_name = .{
        .json = .{
            .deserialize = .{
                .rename = "userName",
                .alias = &.{"username", "user"},
            },
        },
    },
};
// {"userName":"alice"}  →  user_name = "alice"   ✓
// {"username":"alice"}  →  user_name = "alice"   ✓ (alias)
// {"user":"alice"}      →  user_name = "alice"   ✓ (alias)
// {"user_name":"alice"} →  error.MissingField     ✗

skip

Don’t read this field from input. The field retains its default value (or null for optionals):

const Config = struct {
    host: []const u8 = "localhost",
    secret: []const u8 = "hidden",

    pub const serde_fields = .{
        .secret = .{
            .json = .{
                .deserialize = .{ .skip = true },
            },
        },
    },
};
// {"host":"example.com","secret":"leaked"}  →  secret = "hidden"

Validation Rules

The following combinations are compile-time errors:

RuleReason
skip + renameskip prevents the field from participating; rename has no effect
skip + omit_nullskip takes precedence; omit_null never triggers
skip + aliasskip prevents reading; alias has no effect
omit_null on non-optionalthe value can never be null
alias duplicates renameredundant; the rename already matches
alias duplicates field nameredundant; the field name already matches (when no rename)
skip without default or optionalthe field would have no value after skipping
two fields with same serialized keyambiguous output
two fields with same deserialized keyambiguous input matching

Formats

comptime-serde supports multiple serialization formats through a uniform API. All formats share the same Serde(format, T) interface and the same field options system.

JSON

const json = serde.Serde(.json, MyStruct);

// Serialize
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
try json.serialize(&writer, value);
// writer.buffered() contains the JSON output

// Deserialize
var result = try json.deserialize(allocator, json_bytes);
defer result.deinit();

JSON is the reference format. All Zig types map naturally to JSON:

  • Structs → objects
  • Arrays/slices → arrays
  • Strings → strings
  • Numbers → numbers
  • Booleans → booleans
  • Optionals → null or value
  • Enums → string tag

TOML

const toml = serde.Serde(.toml, MyStruct);

TOML has additional structural rules:

  • Top-level struct fields become key-value pairs
  • Nested structs become [table] sections
  • Slices of structs become [[array-of-tables]] sections
  • Slices of primitives become inline arrays
const Config = struct {
    name: []const u8,
    server: struct {
        host: []const u8,
        port: u16,
    },
    tags: []const []const u8,
};

Outputs:

name = "myapp"

[server]
host = "localhost"
port = 8080

tags = ["web", "api"]

YAML

const yaml = serde.Serde(.yaml, MyStruct);

YAML output uses block style:

  • Structs → mappings
  • Arrays/slices → sequences
  • Strings, numbers, booleans → scalars
const Data = struct {
    name: []const u8,
    items: []const u32,
};

Outputs:

name: hello
items:
  - 1
  - 2
  - 3

Protobuf

Protobuf support is work in progress. Zig structs map to protobuf messages:

  • Structs → messages (field numbers assigned by declaration order, 1-based)
  • bool → bool
  • u8/u16/u32 → uint32, u64 → uint64
  • i8/i16/i32 → sint32 (ZigZag), i64 → sint64 (ZigZag)
  • f32 → float (fixed32), f64 → double (fixed64)
  • []const u8 → string/bytes
  • []const T → repeated T (packed encoding for scalars)
  • enum(u32) → enum
  • ?T → optional (omitted when null)

Field numbers can be configured via ProtobufFieldOptions to override declaration order:

pub const serde_fields = .{
    .user_name = .{
        .protobuf = .{
            .field_number = 1,
        },
    },
};

The serde-gen CLI can generate Zig structs from .proto files, including serde_fields with explicit field numbers. See serde-gen CLI.

Cross-Format Configuration

Each format can have independent field options. A field can be renamed differently in each format:

const User = struct {
    name: []const u8,

    pub const serde_fields = .{
        .name = .{
            .json = .{
                .serialize = .{ .rename = "userName" },
                .deserialize = .{ .rename = "userName" },
            },
            .toml = .{
                .serialize = .{ .rename = "user_name" },
                .deserialize = .{ .rename = "user_name" },
            },
            .yaml = .{
                .serialize = .{ .rename = "user-name" },
                .deserialize = .{ .rename = "user-name" },
            },
        },
    };
};

API Reference

dummy html

serde-gen CLI

serde-gen is a command-line tool that infers Zig struct definitions from data files. Point it at a JSON, TOML, YAML, or Proto file and it outputs the corresponding Zig structs to stdout.

Installation

curl -fsSL https://jiacai2050.github.io/comptime-serde/install.sh | sh

Options:

# Install a specific version
sh install.sh --version v0.1.0

# Install to a custom directory
sh install.sh --prefix /usr/local/bin

# Use proxy for users in China
sh install.sh --china

From source

zig build

The binary is written to zig-out/bin/serde-gen.

Usage

serde-gen [OPTIONS] <FILE>

Arguments

ArgumentDescription
FILEInput data file (JSON, TOML, YAML, or Proto). Use - to read from stdin.

Options

OptionShortDescription
--format <fmt>-fForce format (json, toml, yaml, proto). Auto-detected from file extension if omitted.
--root-name <name>Name of the top-level struct (default: Root)
--help-hShow help
--version-vShow version info

Examples

JSON

Given config.json:

{
  "host": "localhost",
  "port": 8080,
  "debug": false,
  "tags": ["web", "api"]
}
$ serde-gen config.json
const Root = struct {
    host: []const u8,
    port: i64,
    debug: bool,
    tags: []const []const u8,
};

Nested Objects

Nested objects become separate structs with capitalized names:

$ serde-gen - <<'EOF'
{"server":{"host":"localhost","port":8080}}
EOF
const Server = struct {
    host: []const u8,
    port: i64,
};

const Root = struct {
    server: Server,
};

Custom Root Name

$ serde-gen --root-name Config config.json
const Config = struct {
    host: []const u8,
    port: i64,
    debug: bool,
    tags: []const []const u8,
};

TOML

$ serde-gen config.toml

YAML

$ serde-gen config.yaml
# or
$ serde-gen config.yml

Proto

Given server.proto:

syntax = "proto3";

enum Status {
  STATUS_UNKNOWN = 0;
  STATUS_ACTIVE = 1;
}

message Server {
  string host = 1;
  uint32 port = 2;
  Status status = 3;
  repeated string tags = 4;
}
$ serde-gen server.proto
const Status = enum(u32) {
    status_unknown = 0,
    status_active = 1,
};

const Server = struct {
    host: []const u8,
    port: u32,
    status: Status,
    tags: []const []const u8,
    pub const serde_fields = .{
        .host = .{ .protobuf = .{ .field_number = 1 } },
        .port = .{ .protobuf = .{ .field_number = 2 } },
        .status = .{ .protobuf = .{ .field_number = 3 } },
        .tags = .{ .protobuf = .{ .field_number = 4 } },
    };
};

Proto output includes serde_fields with explicit field_number values so that the generated structs can be used directly with the protobuf serializer/deserializer.

Type Inference Rules

JSON / TOML / YAML

Data TypeZig Type
string[]const u8
integeri64
floatf64
booleanbool
null?[]const u8
array of T[]const T
object / mappingnested struct (name capitalized from key)
empty array[]const std.json.Value

Proto

Proto TypeZig Type
string, bytes[]const u8
boolbool
int32, sint32, sfixed32i32
int64, sint64, sfixed64i64
uint32, fixed32u32
uint64, fixed64u64
floatf32
doublef64
repeated T[]const T
messagenested struct
enumenum(u32) (values lowercased)

Special Field Names

Field names that are not valid Zig identifiers are wrapped in @"" syntax:

$ serde-gen --format json - <<'EOF'
{"user-name":"alice","2fast":true}
EOF
const Root = struct {
    @"user-name": []const u8,
    @"2fast": bool,
};

Pipe from stdin

Read from stdin by using - as the file path:

echo '{"x":1,"y":2}' | serde-gen --format json -

--format is required when reading from stdin since there is no file extension to auto-detect.

Limitations

serde-gen is an inference tool, not a full parser. It reads one sample file and guesses types from the values present. Keep these limitations in mind.

JSON

  • All integers become i64, all floats become f64. Narrower types (u32, f32, etc.) must be adjusted by hand.
  • Null always infers ?[]const u8. If the actual value is a nullable integer or struct, the type must be corrected manually.
  • Empty arrays default to []const std.json.Value since the element type cannot be inferred.

TOML

  • Hand-rolled parser — only supports a practical subset of the TOML spec. Inline tables (key = { a = 1 }), dotted keys (a.b.c = 1), and datetime values are not handled.
  • Type is inferred from the first occurrence of each key. If the first value is "8080" (a string), the field will be []const u8 even if a later entry has an integer.
  • No nested array-of-tables[[a.b]] sections inside [[a]] are not supported.

YAML

  • Hand-rolled parser — only supports block-style mappings and sequences. Flow style ({a: 1} or [1, 2]), anchors/aliases (*ref), and multi-document streams (---) are not handled.
  • Type is inferred from the first element of a sequence. All subsequent elements are assumed to have the same type.
  • Block scalars (|, >) are recognized but always inferred as []const u8.

Proto

  • Proto3 onlysyntax = "proto2" is not supported.
  • No oneof, map, service, extend, or reserved declarations. These lines are silently skipped.
  • No imports — all types must be defined in the same file.
  • Forward references — a type must be defined before it is referenced. If message A references message B but B is defined later in the file, the generated Zig will have a compile error.
  • Field options ([deprecated = true], etc.) are recognized and ignored in the output.
  • Enum values are lowercased — proto convention STATUS_ACTIVE becomes Zig status_active.