Skip to content

Latest commit

History

16 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

OneSerial: Typed Single-Buffer Serialization For Zig

Serialize supported Zig values into one contiguous byte buffer, then read them through checked or unchecked typed views.

This is useful when you want:

  • Compact binary snapshots of nested data.
  • Fast field/slice access without fully decoding everything.
  • A clear trust boundary (Untrusted -> validate() -> Trusted).

Warning

Experimental: API and wire details may change.
Trust model: Always treat incoming bytes as untrusted until validated.
Portability: Wire format uses native type representation (for example endianness and tag layout), so cross-platform compatibility is limited unless environments match.

Why OneSerial?

Many serializers force full decoding before you can inspect anything. oneserial gives you typed views so you can:

  • Validate once.
  • Traverse fields/slices directly from the byte buffer.
  • Decode to owned memory only when needed.

Quick Start

conststd=@import("std");
constoneserial=@import("oneserial");
constMsg=struct {
id: u64,
user: struct {
name: []constu8,
level: u8,
},
tags: []const []constu8,
maybe_ptr: ?*constu32,
payload: union(enum) {
text: []constu8,
code: u16,
none: void,
},
result: union(enum) { ok: []constu8, offline: void },
};
pubfnmain() !void {
constgpa=std.heap.page_allocator;
varn: u32=7;
constmsg=Msg{
.id=42,
.user= .{ .name="zig", .level=3 },
.tags= &.{ "alpha", "beta", "gamma" },
.maybe_ptr=&n,
.payload= .{ .text="hello" },
.result= .{ .ok="ok" },
};
varwrapper=tryoneserial.Wrapper(Msg, .{}).init(&msg, gpa);
deferwrapper.deinit(gpa);
// Validate once, then use trusted typed views.consttrusted=trywrapper.untrusted().validate();
constid=trusted.field("id").value();
consttags=trusted.field("tags");
std.debug.print("id={d}, tags={d}\n", .{ id, tags.len() });
}

Installation

  1. Add dependency in build.zig.zon:
.dependencies= .{
.oneserial= .{
.url="git+https://github.com/SmallThingz/oneserial#<commit>",
.hash="<hash>",
},
},
  1. Add module import in build.zig:
constdep=b.dependency("oneserial", .{
.target=target,
.optimize=optimize,
});
exe.root_module.addImport("oneserial", dep.module("oneserial"));

Core API

  • oneserial.Converter(T, .{})
    • Entry point for per-type operations.
  • oneserial.serializeAlloc(T, .{}, &value, allocator)
    • Serialize value into one aligned byte buffer.
  • oneserial.allocFromShim(T, .{}, &shim, allocator)
    • Allocate dynamic shape from a shim value without copying payload bytes.
  • oneserial.invalidPointer(P)
    • Pointer sentinel for shim fields where recursion should stop.
  • oneserial.Wrapper(T, .{})
    • Owns serialized bytes and provides .untrusted().
  • oneserial.Untrusted(T, .{})
    • Checked access. Call .validate() for full-buffer validation.
  • oneserial.Trusted(T, .{})
    • Assumes bytes are valid; cheaper typed access.

Endianness

MergeOptions has endian (default: native). Use it when producing or consuming non-native wire bytes:

constopposite: std.builtin.Endian=if (@import("builtin").target.cpu.arch.endian() ==.little) .bigelse.little;
constbytes=tryoneserial.serializeAlloc(MyType, .{ .endian=opposite }, &value, allocator);
constu=oneserial.Untrusted(MyType, .{}).init(bytes).withEndian(opposite);
consttrusted=tryu.validate();

Shim Allocation

allocFromShim is useful when you only know allocation shape (lengths/presence/tag) and want to fill payload bytes later.

constT=struct {
a: []constu8,
b: []constu8,
};
consts=oneserial.invalidPointer([*]constu8);
constshim=T{
.a=s[0..8],
.b=s[0..32],
};
constout=tryoneserial.allocFromShim(T, .{}, &shim, allocator);
// out.a/out.b are allocated with matching lengths; payload bytes are not copied.

Nested shapes are supported:

constT=struct { a: []const []constu8 };
consts=oneserial.invalidPointer([*]constu8);
constinner= [_][]constu8{
s[0..4],
s[0..2],
};
constshim=T{ .a=inner[0..] };
constout=tryoneserial.allocFromShim(T, .{}, &shim, allocator);

When a pointer (or slice .ptr) equals invalidPointer(...), OneSerial allocates that container but does not recurse deeper into pointee/element payloads.

For []const []const u8 specifically:

  • Outer slice sentinel ([*]const []const u8) with len = N allocates N inner-slice headers.
  • Inner []const u8 element sentinels allocate each inner byte buffer by its len.

Important

Values returned by allocFromShim may contain undefined non-shape data.
You must initialize payload data before reading it.

View Accessors

Available on Untrusted, Trusted, and nested views as type-appropriate:

  • .field("name") for structs
  • .get() for dynamic values
  • .len() / .at(i) / .atUnchecked(i) for slices
  • .value() to decode the current view value
  • .toOwned(allocator) to allocate and decode owned value

Supported Types

  • Primitives: void, bool, integers, floats, vectors, null
  • Containers: arrays, structs, tagged unions, optionals
  • Indirection: *T (one pointers) and []T (slices)
  • Enums

Not Supported

  • [*]T and [*c]T
  • error and error_union values
  • Untagged unions
  • type, noreturn, comptime-only value types, opaque, standalone error_set, function/frame types

Limitations

  1. Recursive types are supported, but recursive cyclic data is not. Cycles can recurse forever.
  2. This format is intentionally low-level and destructive: it prioritizes speed and simple traversal over schema evolution guarantees.
  3. Trusted access should only be used after validation or in already-trusted contexts.

Development

Run tests:

zig build test

About

Serialize types in zig for cross platform use.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages