1
1
Fork
You've already forked serde.zig
0
Universal serialization for Zig: JSON, Yaml, XML, MessagePack, TOML, CSV and more from a single API. msgpack.org[Zig] https://github.com/OrlovEvgeny/serde.zig
  • Zig 100%
Find a file
Evgenii Orlov 5ce9632ae1
Merge pull request #5 from OrlovEvgeny/feature/oob-type-overrides
Add comptime map parameter for customizing serialization of types without modifying them
2026年03月23日 09:15:24 +01:00
.github fix: update Zig version to 0.15.2 in CI and build.zig.zon 2026年03月06日 16:46:34 +01:00
src feat: add out-of-band type overrides via comptime map 2026年03月21日 17:41:38 +01:00
test feat: add out-of-band type overrides via comptime map 2026年03月21日 17:41:38 +01:00
.gitignore add out of band schema 2026年03月07日 18:23:09 +01:00
build.zig feat: add xml serder 2026年03月08日 09:22:45 +01:00
build.zig.zon fix: update Zig version to 0.15.2 in CI and build.zig.zon 2026年03月06日 16:46:34 +01:00
LICENSE Initial commit 2026年03月06日 10:34:33 +01:00
README.md feat: add out-of-band type overrides via comptime map 2026年03月21日 17:41:38 +01:00

serde.zig

Build Release Zig

Serialization framework for Zig

Uses Zig's comptime reflection (@typeInfo) to serialize and deserialize any Zig type across JSON, MessagePack, TOML, YAML, XML, ZON, and CSV without macros, code generation, or runtime type information.

constserde=@import("serde");constUser=struct{name:[]constu8,age:u32,email:?[]constu8=null,};// Serialize to JSONconstjson_bytes=tryserde.json.toSlice(allocator,User{.name="Alice",.age=30,.email="alice@example.com",});// => {"name":"Alice","age":30,"email":"alice@example.com"}// Deserialize from JSONconstuser=tryserde.json.fromSlice(User,allocator,json_bytes);

Installation

Latest version from master:

zig fetch --save git+https://github.com/OrlovEvgeny/serde.zig

Specific release:

zig fetch --save https://github.com/OrlovEvgeny/serde.zig/archive/refs/tags/v0.1.2.tar.gz

Then in your build.zig:

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

Requires Zig 0.15.0 or later.

Formats

Format Module Serialize Deserialize
JSON serde.json + +
MessagePack serde.msgpack + +
TOML serde.toml + +
YAML serde.yaml + +
XML serde.xml + +
ZON serde.zon + +
CSV serde.csv + +

Every format exposes the same API:

// Serializationconstbytes=tryserde.json.toSlice(allocator,value);tryserde.json.toWriter(&writer,value);// Deserializationconstval=tryserde.json.fromSlice(T,allocator,bytes);constval=tryserde.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

Nested structs

constAddress=struct{street:[]constu8,city:[]constu8,zip:[]constu8,};constPerson=struct{name:[]constu8,age:u32,address:Address,tags:[]const[]constu8,};constperson=Person{.name="Bob",.age=25,.address=.{.street="123 Main St",.city="Springfield",.zip="62704"},.tags=&.{"admin","active"},};constjson=tryserde.json.toSlice(allocator,person);constmsgpack=tryserde.msgpack.toSlice(allocator,person);constyaml=tryserde.yaml.toSlice(allocator,person);constxml=tryserde.xml.toSlice(allocator,person);

Deserialization allocates memory for strings, slices, and nested structures. Use an ArenaAllocator for easy cleanup:

vararena=std.heap.ArenaAllocator.init(std.heap.page_allocator);deferarena.deinit();constuser=tryserde.json.fromSlice(User,arena.allocator(),json_bytes);

Zero-copy deserialization

When strings in the JSON input contain no escape sequences, fromSliceBorrowed returns slices pointing directly into the input buffer:

constinput="{\"name\":\"alice\",\"id\":1}";constmsg=tryserde.json.fromSliceBorrowed(Msg,allocator,input);// msg.name points into input, input must outlive msg

Pretty-printed output

constpretty=tryserde.json.toSliceWith(allocator,value,.{.pretty=true,.indent=2});// {// "name": "Alice",// "age": 30// }

Tagged unions

constCommand=union(enum){ping:void,execute:struct{query:[]constu8},quit:void,};constcmd=Command{.execute=.{.query="SELECT 1"}};constbytes=tryserde.json.toSlice(allocator,cmd);// => {"execute":{"query":"SELECT 1"}}

Enums

constColor=enum{red,green,blue};constbytes=tryserde.json.toSlice(allocator,Color.blue);// => "blue"constcolor=tryserde.json.fromSlice(Color,allocator,bytes);// => Color.blue

Maps

varmap=std.StringHashMap(i32).init(allocator);defermap.deinit();trymap.put("a",1);trymap.put("b",2);constbytes=tryserde.json.toSlice(allocator,map);// => {"a":1,"b":2}

CSV

constRecord=struct{name:[]constu8,age:u32,active:bool,};constrecords:[]constRecord=&.{.{.name="Alice",.age=30,.active=true},.{.name="Bob",.age=25,.active=false},};constcsv_bytes=tryserde.csv.toSlice(allocator,records);// name,age,active// Alice,30,true// Bob,25,false

TOML

constConfig=struct{title:[]constu8,port:u16=8080,database:struct{host:[]constu8,name:[]constu8,},};constcfg=tryserde.toml.fromSlice(Config,arena.allocator(),\\title = "myapp"\\port = 3000\\\\[database]\\host = "localhost"\\name = "mydb");

YAML

constServer=struct{host:[]constu8,port:u16,debug:bool,};constyaml_input=\\host: localhost\\port: 8080\\debug: true;constserver=tryserde.yaml.fromSlice(Server,arena.allocator(),yaml_input);constyaml_bytes=tryserde.yaml.toSlice(allocator,server);// host: localhost// port: 8080// debug: true

XML

constUser=struct{id:u64,name:[]constu8,role:[]constu8,pubconstserde=.{.xml_attribute=.{.id},.xml_root="user",};};constxml_bytes=tryserde.xml.toSlice(allocator,User{.id=42,.name="Alice",.role="admin",});// <?xml version="1.0" encoding="UTF-8"?>// <user id="42"><name>Alice</name><role>admin</role></user>constuser=tryserde.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

Produces valid .zon files:

constbytes=tryserde.zon.toSlice(allocator,Config{.title="myapp",.port=3000,.database=.{.host="localhost",.name="mydb"},});// .{// .title = "myapp",// .port = 3000,// .database = .{// .host = "localhost",// .name = "mydb",// },// }

Serde Options

Customize serialization behavior by declaring pub const serde on your types. All options are resolved at comptime.

Field renaming

constUser=struct{user_id:u64,first_name:[]constu8,last_name:[]constu8,pubconstserde_options=.{.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.

Skip fields

constSecret=struct{name:[]constu8,token:[]constu8,email:?[]constu8,tags:[]const[]constu8,pubconstserde=.{.skip=.{.token=serde.SkipMode.always,.email=serde.SkipMode.@"null",.tags=serde.SkipMode.empty,},};};

Default values

Zig's struct default values are used during deserialization when a field is absent from the input:

constConfig=struct{name:[]constu8,retries:i32=3,timeout:i32=30,};constcfg=tryserde.json.fromSlice(Config,allocator,"{\"name\":\"app\"}");// cfg.retries == 3, cfg.timeout == 30

Deny unknown fields

constStrict=struct{x:i32,pubconstserde=.{.deny_unknown_fields=true,};};// Returns error.UnknownField if input contains unexpected keys

Flatten nested structs

constMetadata=struct{created_by:[]constu8,version:i32=1,};constUser=struct{name:[]constu8,meta:Metadata,pubconstserde=.{.flatten=&[_][]constu8{"meta"},};};// Serializes as: {"name":"Alice","created_by":"admin","version":2}// instead of: {"name":"Alice","meta":{"created_by":"admin","version":2}}

Union tagging styles

constCommand=union(enum){ping:void,execute:struct{query:[]constu8},pubconstserde_options=.{// .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

constStatus=enum(u8){active=0,inactive=1,pending=2,pubconstserde_options=.{.enum_repr=serde.EnumRepr.integer,// serialize as 0, 1, 2};};// Default is .string: "active", "inactive", "pending"

Per-field custom serialization

constEvent=struct{name:[]constu8,created_at:i64,pubconstserde_options=.{.with=.{.created_at=serde.helpers.UnixTimestampMs,},};};

Built-in helpers: serde.helpers.UnixTimestamp, serde.helpers.UnixTimestampMs, serde.helpers.Base64.

Out-of-Band Schema

Override serialization behavior externally, without modifying the type. Useful for third-party types you don't control, or when the same type needs different wire representations in different contexts.

constPoint=struct{x:f64,y:f64,z:f64};// External schema: rename fields, skip zconstschema=.{.rename=.{.x="X",.y="Y"},.skip=.{.z=serde.SkipMode.always},};constpoint=Point{.x=1.0,.y=2.0,.z=3.0};// Serialize with schemaconstbytes=tryserde.json.toSliceSchema(allocator,point,schema);// => {"X":1.0e0,"Y":2.0e0}// Deserialize with schemaconstp=tryserde.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:

constfull_schema=.{.rename_all=serde.NamingConvention.SCREAMING_SNAKE_CASE,};constcompact_schema=.{.rename=.{.x="a",.y="b"},.skip=.{.z=serde.SkipMode.always},};constfull=tryserde.json.toSliceSchema(allocator,point,full_schema);// => {"X":1.0e0,"Y":2.0e0,"Z":3.0e0}constcompact=tryserde.json.toSliceSchema(allocator,point,compact_schema);// => {"a":1.0e0,"b":2.0e0}

Schema supports all the same options as pub const serde: rename, rename_all, skip, default, with, deny_unknown_fields, flatten, tag, tag_field, content_field, enum_repr.

When both an external schema and pub const serde exist on a type, the external schema takes priority.

All *Schema variants are available on every format module: toSliceSchema, toWriterSchema, fromSliceSchema, fromReaderSchema, etc.

Out-of-Band Type Overrides

Override how specific types are serialized/deserialized at the call site, without modifying the type. This is useful for third-party types you don't own (e.g. std.ArrayList, external library structs) or when you need a one-off representation.

Pass a comptime map of {Type, Adapter} pairs to the *WithMap functions:

conststd=@import("std");constserde=@import("serde");// A type from a library you don't controlconstTimestamp=struct{seconds:i64,nanos:u32,};// Define how to serialize/deserialize itconstTimestampAdapter=struct{pubfnserialize(value:Timestamp,s:anytype)@TypeOf(s.*).Error!void{// Serialize as a single float: seconds.nanosconstms:f64=@as(f64,@floatFromInt(value.seconds))+@as(f64,@floatFromInt(value.nanos))/1_000_000_000.0;trys.serializeFloat(ms);}pubfndeserialize(comptime_:type,_:std.mem.Allocator,d:anytype,)@TypeOf(d.*).Error!Timestamp{constval=tryd.deserializeFloat(f64);constsecs:i64=@intFromFloat(val);constnanos:u32=@intFromFloat((val-@as(f64,@floatFromInt(secs)))*1_000_000_000.0);return.{.seconds=secs,.nanos=nanos};}};// Build the map and use itconstmap=.{.{Timestamp,TimestampAdapter}};constEvent=struct{name:[]constu8,at:Timestamp,};constevent=Event{.name="deploy",.at=.{.seconds=1700000000,.nanos=500000000},};constbytes=tryserde.json.toSliceWithMap(allocator,event,map);// => {"name":"deploy","at":1700000000.5}vararena=std.heap.ArenaAllocator.init(allocator);deferarena.deinit();constresult=tryserde.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

Every format module provides map-aware variants:

// Serializeconstbytes=tryserde.json.toSliceWithMap(allocator,value,map);tryserde.json.toWriterWithMap(&writer,value,map);// Deserializeconstval=tryserde.json.fromSliceWithMap(T,allocator,bytes,map);constval=tryserde.json.fromSliceBorrowedWithMap(T,allocator,bytes,map);constval=tryserde.json.fromReaderWithMap(T,allocator,&reader,map);

For more control, use the core functions directly:

tryserde.serializeWith(T,value,&serializer,map);constval=tryserde.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

constArrayListAdapter=struct{pubfnserialize(value:std.ArrayList(u8),s:anytype)@TypeOf(s.*).Error!void{trys.serializeString(value.items);}pubfndeserialize(comptime_:type,allocator:std.mem.Allocator,d:anytype,)@TypeOf(d.*).Error!std.ArrayList(u8){conststr=tryd.deserializeString(allocator);varlist=std.ArrayList(u8).empty;// steal the allocated string bufferlist.items=@constCast(str);list.capacity=str.len;list.items.len=str.len;returnlist;}};constmap=.{.{std.ArrayList(u8),ArrayListAdapter}};constResponse=struct{status:u16,body:std.ArrayList(u8),};constresp=Response{.status=200,.body=blk:{varb=std.ArrayList(u8).empty;tryb.appendSlice(allocator,"OK");break:blkb;},};constbytes=tryserde.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:

constStringWrappedU64=struct{inner:u64,pubfnzerdeSerialize(self:@This(),serializer:anytype)!void{varbuf:[20]u8=undefined;consts=std.fmt.bufPrint(&buf,"{d}",.{self.inner})catchunreachable;tryserializer.serializeString(s);}pubfnzerdeDeserialize(comptime_:type,allocator:std.mem.Allocator,deserializer:anytype,)@TypeOf(deserializer.*).Error!@This(){conststr=trydeserializer.deserializeString(allocator);deferallocator.free(str);return.{.inner=std.fmt.parseInt(u64,str,10)catchreturnerror.InvalidNumber};}};constbytes=tryserde.json.toSlice(allocator,StringWrappedU64{.inner=12345});// => "12345"

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.WrongType -- input type doesn't match target type
  • error.DuplicateField -- same field appears twice
constresult=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=>returnerr,};

Tests

zig build test

License

MIT