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.
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_fieldsdeclaration on your type. - Arena-backed deserialization: Deserialized values are returned in a
Parsed(T)wrapper with an arena allocator. Calldeinit()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 Type | JSON | TOML | YAML |
|---|---|---|---|
bool | true/false | true/false | true/false |
u8–u64, i8–i64 | number | integer | integer |
f32, f64 | number | float | float |
[]const u8 | string | string | string |
?T | null or value | omitted or value | null or value |
[N]T | array | array | sequence |
[]T | array | array of tables | sequence |
struct { ... } | object | table | mapping |
enum { ... } | string | string | string |
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 inputerror.DuplicateField— the same key appears twice (JSON)error.UnexpectedToken— malformed inputerror.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
| Option | Type | Default | Description |
|---|---|---|---|
rename | ?[]const u8 | null | Output this key instead of the Zig field name |
skip | bool | false | Omit this field from output entirely |
omit_null | bool | false | Omit 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
| Option | Type | Default | Description |
|---|---|---|---|
rename | ?[]const u8 | null | Accept this key instead of the Zig field name |
skip | bool | false | Don’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:
| Rule | Reason |
|---|---|
skip + rename | skip prevents the field from participating; rename has no effect |
skip + omit_null | skip takes precedence; omit_null never triggers |
skip + alias | skip prevents reading; alias has no effect |
omit_null on non-optional | the value can never be null |
alias duplicates rename | redundant; the rename already matches |
alias duplicates field name | redundant; the field name already matches (when no rename) |
| skip without default or optional | the field would have no value after skipping |
| two fields with same serialized key | ambiguous output |
| two fields with same deserialized key | ambiguous 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 →
nullor 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→ boolu8/u16/u32→ uint32,u64→ uint64i8/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
From release (recommended)
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
| Argument | Description |
|---|---|
FILE | Input data file (JSON, TOML, YAML, or Proto). Use - to read from stdin. |
Options
| Option | Short | Description |
|---|---|---|
--format <fmt> | -f | Force format (json, toml, yaml, proto). Auto-detected from file extension if omitted. |
--root-name <name> | Name of the top-level struct (default: Root) | |
--help | -h | Show help |
--version | -v | Show 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 Type | Zig Type |
|---|---|
| string | []const u8 |
| integer | i64 |
| float | f64 |
| boolean | bool |
| null | ?[]const u8 |
| array of T | []const T |
| object / mapping | nested struct (name capitalized from key) |
| empty array | []const std.json.Value |
Proto
| Proto Type | Zig Type |
|---|---|
string, bytes | []const u8 |
bool | bool |
int32, sint32, sfixed32 | i32 |
int64, sint64, sfixed64 | i64 |
uint32, fixed32 | u32 |
uint64, fixed64 | u64 |
float | f32 |
double | f64 |
repeated T | []const T |
message | nested struct |
enum | enum(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 becomef64. 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.Valuesince 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 u8even 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 only —
syntax = "proto2"is not supported. - No
oneof,map,service,extend, orreserveddeclarations. 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 Areferencesmessage BbutBis 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_ACTIVEbecomes Zigstatus_active.