zig-skills/references/std-json.md

10 KiB

std.json - JSON Parsing and Serialization

JSON RFC 8259 compliant parsing and stringification. In Zig 0.16, streaming/file examples should use std.Io.Reader, std.Io.Writer, std.Io.Dir, and an explicit std.Io.

Primary Zig 0.16 release-note source: https://ziglang.org/download/0.16.0/release-notes.html

Table of Contents

Parsing JSON

Parse into Struct

const Config = struct {
    name: []const u8,
    port: u16,
    enabled: bool = true,  // default value for missing fields
};

const json_str =
    \\{"name": "server", "port": 8080}
;

const parsed = try std.json.parseFromSlice(Config, allocator, json_str, .{});
defer parsed.deinit();

const config = parsed.value;
// config.name == "server"
// config.port == 8080
// config.enabled == true (default)

ParseOptions

const parsed = try std.json.parseFromSlice(T, allocator, json_str, .{
    // What to do with duplicate fields
    .duplicate_field_behavior = .@"error",  // .use_first, .use_last, .@"error" (default)

    // Allow unknown fields (default: error)
    .ignore_unknown_fields = true,

    // Max string/number length (default: input length for slices)
    .max_value_len = 4096,

    // Parse numbers vs keep as strings
    .parse_numbers = true,  // default: true
});

Supported Types

Zig Type JSON
bool true, false
i32, u64, etc. number or string
f32, f64 number or string
?T value or null
[]const u8 string
[N]u8 string (fixed length)
[]T, [N]T array
struct object
union(enum) object with single field
enum string
std.json.Value any JSON value

Parse into Dynamic Value

Use std.json.Value when structure is unknown at compile time:

const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{});
defer parsed.deinit();

const value = parsed.value;
switch (value) {
    .object => |obj| {
        if (obj.get("name")) |name| {
            std.debug.print("name: {s}\n", .{name.string});
        }
    },
    .array => |arr| {
        for (arr.items) |item| { ... }
    },
    .string => |s| { ... },
    .integer => |i| { ... },
    .float => |f| { ... },
    .bool => |b| { ... },
    .null => { ... },
    .number_string => |s| { ... },  // unparsed number
}

Leaky Parsing (Arena Allocator)

When using an arena, skip the Parsed wrapper:

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

const config = try std.json.parseFromSliceLeaky(
    Config,
    arena.allocator(),
    json_str,
    .{},
);
// No deinit needed - arena handles cleanup

Serializing to JSON

Simple Serialization

const config = Config{ .name = "app", .port = 3000 };

// To allocated string
const json = try std.json.Stringify.valueAlloc(allocator, config, .{});
defer allocator.free(json);
// json == {"name":"app","port":3000}

// To writer
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
try std.json.Stringify.value(config, .{}, &writer.interface);
try writer.interface.flush();

Stringify Options

try std.json.Stringify.value(data, .{
    // Whitespace formatting
    .whitespace = .minified,     // default: no whitespace
    // .whitespace = .indent_2,  // 2-space indent
    // .whitespace = .indent_4,  // 4-space indent
    // .whitespace = .indent_tab,

    // Include null optional fields? (default: true)
    .emit_null_optional_fields = false,

    // Emit []u8 as array of numbers instead of string
    .emit_strings_as_arrays = false,

    // Escape non-ASCII unicode as \uXXXX
    .escape_unicode = false,

    // Large integers as strings for JS compatibility
    .emit_nonportable_numbers_as_strings = false,
}, writer);

Supported Types for Serialization

  • booltrue/false
  • ?T → value or null
  • integers → number (or string if > 2^53 with option)
  • floats → number (or string if not precisely representable as f64)
  • []const u8 → string (or array with option)
  • []T, [N]T → array
  • tuples → array
  • struct → object (fields in declaration order)
  • union(enum) → object with one field
  • enum → string
  • *T → serialization of T
  • error → string

Dynamic Values

Value Type

pub const Value = union(enum) {
    null,
    bool: bool,
    integer: i64,
    float: f64,
    number_string: []const u8,  // unparsed number
    string: []const u8,
    array: Array,               // std.ArrayList(Value)
    object: ObjectMap,          // StringArrayHashMap(Value)
};

Building Values Manually

var obj = std.json.ObjectMap.init(allocator);
try obj.put("name", .{ .string = "test" });
try obj.put("count", .{ .integer = 42 });

var arr = std.json.Array.init(allocator);
try arr.append(.{ .integer = 1 });
try arr.append(.{ .integer = 2 });
try obj.put("items", .{ .array = arr });

const value = std.json.Value{ .object = obj };

Accessing Values

// Object access
if (value.object.get("key")) |v| {
    switch (v) {
        .string => |s| std.debug.print("{s}\n", .{s}),
        else => {},
    }
}

// Array iteration
for (value.array.items) |item| {
    if (item == .integer) {
        std.debug.print("{d}\n", .{item.integer});
    }
}

Custom Serialization

Custom jsonParse

Define jsonParse for custom deserialization:

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

    // Parse from "x,y" string format
    pub fn jsonParse(
        allocator: std.mem.Allocator,
        source: anytype,
        options: std.json.ParseOptions,
    ) !@This() {
        _ = allocator;
        _ = options;
        const token = try source.next();
        const str = switch (token) {
            .string, .allocated_string => |s| s,
            else => return error.UnexpectedToken,
        };
        var it = std.mem.splitScalar(u8, str, ',');
        return .{
            .x = try std.fmt.parseInt(i32, it.next() orelse return error.UnexpectedToken, 10),
            .y = try std.fmt.parseInt(i32, it.next() orelse return error.UnexpectedToken, 10),
        };
    }
};

// Parses: "10,20" → Point{ .x = 10, .y = 20 }

Custom jsonStringify

Define jsonStringify for custom serialization:

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

    pub fn jsonStringify(self: @This(), jw: anytype) !void {
        // Serialize as "x,y" string
        try jw.print("\"{d},{d}\"", .{ self.x, self.y });
    }
};

// Serializes: Point{ .x = 10, .y = 20 } → "10,20"

Streaming API

Stringify (Write Stream)

Build JSON incrementally:

var out: std.io.Writer.Allocating = .init(allocator);
defer out.deinit();

var jw: std.json.Stringify = .{
    .writer = &out.writer,
    .options = .{ .whitespace = .indent_2 },
};

try jw.beginObject();
try jw.objectField("users");
try jw.beginArray();

for (users) |user| {
    try jw.beginObject();
    try jw.objectField("name");
    try jw.write(user.name);
    try jw.objectField("age");
    try jw.write(user.age);
    try jw.endObject();
}

try jw.endArray();
try jw.endObject();

const json = out.written();

Scanner (Low-Level Parsing)

Token-based parsing for streaming:

var scanner = std.json.Scanner.initCompleteInput(allocator, json_str);
defer scanner.deinit();

while (true) {
    const token = try scanner.next();
    switch (token) {
        .object_begin => { ... },
        .object_end => { ... },
        .array_begin => { ... },
        .array_end => { ... },
        .string => |s| { ... },
        .number => |n| { ... },
        .true, .false, .null => { ... },
        .end_of_document => break,
        else => {},
    }
}

Common Patterns

Config File Loading

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

fn loadConfig(allocator: std.mem.Allocator, path: []const u8) !Config {
    const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
        error.FileNotFound => return Config{},  // defaults
        else => return err,
    };
    defer file.close();

    const content = try file.readToEndAlloc(allocator, 1024 * 1024);
    defer allocator.free(content);

    const parsed = try std.json.parseFromSlice(Config, allocator, content, .{
        .ignore_unknown_fields = true,
    });
    defer parsed.deinit();

    // Copy strings to owned memory since parsed will be freed
    return Config{
        .host = try allocator.dupe(u8, parsed.value.host),
        .port = parsed.value.port,
        .debug = parsed.value.debug,
    };
}

API Response Handling

const ApiResponse = struct {
    success: bool,
    data: ?Data = null,
    @"error": ?[]const u8 = null,  // use @"error" for reserved words

    const Data = struct {
        id: u64,
        name: []const u8,
    };
};

fn handleResponse(json: []const u8, allocator: std.mem.Allocator) !void {
    const parsed = try std.json.parseFromSlice(ApiResponse, allocator, json, .{
        .ignore_unknown_fields = true,
    });
    defer parsed.deinit();

    if (!parsed.value.success) {
        std.debug.print("Error: {s}\n", .{parsed.value.@"error" orelse "unknown"});
        return error.ApiError;
    }

    if (parsed.value.data) |data| {
        std.debug.print("Got: {s} (id={})\n", .{ data.name, data.id });
    }
}

Pretty Print JSON

fn prettyPrint(allocator: std.mem.Allocator, json: []const u8) ![]u8 {
    const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{});
    defer parsed.deinit();

    return std.json.Stringify.valueAlloc(allocator, parsed.value, .{
        .whitespace = .indent_2,
    });
}

Serialize with Filtering

fn serializePublicFields(allocator: std.mem.Allocator, user: User) ![]u8 {
    // Create anonymous struct with only public fields
    const public = .{
        .id = user.id,
        .name = user.name,
        // Exclude: .password, .internal_state
    };
    return std.json.Stringify.valueAlloc(allocator, public, .{});
}