Custom JSON serialization in Zig

Zig's JSON support is pretty solid, but sometimes you need exact control over how data is serialized (or maybe you're dealing with data that Zig can't natively serialize).

Somewhat like Go's json.Marshaller, Zig will use a type's jsonStringify function if it's defined. This API recently went through heavy changes, but the gist is that you get a WriteStream instance that exposes a write and print function.

As a simple example, imagine that for whatever reason, we want to encode booleans using 1 and 0 instead of true and false. To achieve this, we'd need to create a wrapper around a boolean value that implements jsonStringify:

const NumericBoolean = struct {
  value: bool,

  pub fn jsonStringify(self: NumericBoolean, out: anytype) !void {
    const json = if (self.value) "1" else "0";
    return out.print("{s}", .{json});
  }
};

Which we can use like so:

pub fn main() !void {
  var gpa = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = gpa.allocator();

  var arr = std.ArrayList(u8).init(allocator);
  try std.json.stringify(.{
    .accept = NumericBoolean{.value = true},
  }, .{}, arr.writer());

  std.debug.print("{s}\n", .{arr.items});
}

Outputting: {"accept":1}.

The print that we're using above writes exactly what we give it, while still maintaining the correct internal state (e.g. adding a comma after a key=>value pair).

The alternative, write, can also be used to generate the same output. Where print takes a formatted input, write takes anytype and will JSON serialize the value.

  pub fn jsonStringify(self: NumericBoolean, out: anytype) !void {
    const json: u8 = if (self.value) 1 else 0;
    return out.write(json);
  }

Speaking of anytype, you'll notice that our out is also declare an anytype and not a *std.json.WriteStream. This is because the WriteStream is actually a generic and there's really no way to tell its exact type. Its type will change based on whether it wraps, for example, an ArrayList's writer or a File's writer.

As a final example, let me share the code that lead me to this post. Aolium uses sqlite, which doesn't have native array support. A post's tags are thus stored as a serialized JSON array in a text field, e.g.: '["zig","json"]'. (I have a small obsession with storing pre-serialized JSON in databases). When fetching a post from the DB to return it in the API, the code looks something like:

try std.json.stringify(.{
  .id = row.text(0),
  .title = row.nullableText(1),
  .tags = row.nullableText(2),
  // ...
}, .{}, writer);

This doesn't work though: tags is already a string in the database and now we're re-stringifying it. We'd end up with an encoded string: '"[\\"zig\\",\\"json\\"]"'.

We could parse th string into an array, and then let json.stringify convert it back. But that's wasteful. Instead, we'll create Raw type with a custom jsonStringify function:

pub const Raw = struct {
  value: ?[]const u8,

  pub fn init(value: ?[]const u8) Raw {
    return .{.value = value};
  }

  pub fn jsonStringify(self: Raw, out: anytype) !void {
    const json = if (self.value) |value| value else "null";
    return out.print("{s}", .{json});
  }
};

Which we can use as such:

try std.json.stringify(.{
  .id = row.text(0),
  .title = row.nullableText(1),
  .tags = Raw.init(row.nullableText(2)),
  // ...
}, .{}, writer);

We no longer need to parse the JSON only to have it immediately re-serialized.

If you're looking for the opposite of jsonStringify, i.e. a way to specify a custom JSON parser for a type, you're out of luck. For the time being at least, Zig doesn't have such support.

Comments

Great post! I think Aolium has some potential to be a minimalist alternative to all the greedy platforms out there… Anyway, I really wish we could easily implement custom JSON parsers in Zig through a standard API (like CodingKeys in Swift)…

Leave a Comment

All comments are reviewed before being made public.