Skip to content

Announcing Getty 0.3.0

I am happy to announce a new version of Getty, 0.3.0. 🎉

Getty is a framework for building robust, optimal, and reusable serializers/deserializers in Zig. To install Getty, follow the instructions listed on the Installation page.

What's in 0.3.0

Deserialize anything

The deserializeAny method has been added to the getty.Deserializer interface.

deserializeAny enables deserializers of self-describing formats (e.g., JSON) to drive themselves based on their own input data. This can be useful if, for instance, a user wants to deserialize into a value from multiple possible data types (e.g., JSON array or JSON object).

Zig code
const std = @import("std");
const getty = @import("getty");
const json = @import("json");

const Allocator = std.mem.Allocator;

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

    pub const @"getty.db" = struct {
        // 👋 Here, we call deserializeAny to let the deserializer drive
        //    itself.
        pub fn deserialize(a: ?Allocator, comptime _: type, d: anytype, v: anytype) !Point {
            return try d.deserializeAny(a, v);
        }

        // 👋 Here, we define a visitor that is able to produce Point values
        //    from both Maps and Sequences.
        pub fn Visitor(comptime _: type) type {
            return struct {
                pub usingnamespace getty.de.Visitor(
                    @This(),
                    Point,
                    .{
                        .visitMap = visitMap,
                        .visitSeq = visitSeq,
                    },
                );

                pub fn visitMap(_: @This(), a: ?Allocator, comptime _: type, map: anytype) !Point {
                    var point: Point = undefined;

                    while (try map.nextKey(a, []const u8)) |key| {
                        if (std.mem.eql(u8, "x", key)) {
                            point.x = try map.nextValue(a, i32);
                            continue;
                        }

                        if (std.mem.eql(u8, "y", key)) {
                            point.y = try map.nextValue(a, i32);
                            continue;
                        }

                        return error.UnknownField;
                    }

                    return point;
                }

                pub fn visitSeq(_: @This(), a: ?Allocator, comptime _: type, seq: anytype) !Point {
                    var point: Point = undefined;

                    inline for (std.meta.fields(Point)) |field| {
                        if (try seq.nextElement(a, i32)) |elem| {
                            @field(point, field.name) = elem;
                        }
                    }

                    if (try seq.nextElement(a, getty.de.Ignored) != null) {
                        return error.InvalidLength;
                    }

                    return point;
                }
            };
        }
    };
};

pub fn main() !void {
    // 👋 Here, we deserialize into a Point from a JSON object.
    const str1 = "{\"x\":1,\"y\":2}";
    const point1 = try json.fromSlice(std.heap.page_allocator, Point, str1);

    // 👋 Here, we deserialize into a Point from a JSON array.
    const str2 = "[1,2]";
    const point2 = try json.fromSlice(null, Point, str2);

    std.debug.print("{}\n", .{point1});
    std.debug.print("{}\n", .{point2});
}
Shell session
$ zig build run
main.Point{ .x = 1, .y = 2 }
main.Point{ .x = 1, .y = 2 }

New union attributes

The skip and rename attributes are now supported by unions.

  • Renaming a union variant will change the tag that Getty uses during (de)serialization.

    Zig code
    const std = @import("std");
    const json = @import("json");
    
    const allocator = std.heap.page_allocator;
    
    const Union = union(enum) {
        foo: i32,
        bar: i32,
    
        pub const @"getty.sb" = block;
        pub const @"getty.db" = block;
    
        const block = struct {
            pub const attributes = .{
                .foo = .{ .rename = "FOO" },
            };
        };
    };
    
    pub fn main() !void {
        const s = try json.toSlice(allocator, Union{ .foo = 1 });
        defer allocator.free(s);
    
        const d = try json.fromSlice(null, Union, s);
    
        std.debug.print("{s}\n", .{s});
        std.debug.print("{}\n", .{d});
    }
    
    Shell session
    {"FOO":1}
    main.Union{ .foo = 1 }
    
  • Attempting to (de)serialize a skipped union variant will return an UnknownVarianterror.

    Zig code
    const std = @import("std");
    const json = @import("json");
    
    const allocator = std.heap.page_allocator;
    
    const Union = union(enum) {
        foo: i32,
        bar: i32,
    
        pub const @"getty.sb" = struct {
            pub const attributes = .{
                .bar = .{ .skip = true },
            };
        };
    };
    
    pub fn main() !void {
        _ = try json.toSlice(allocator, Union{ .bar = 1 });
    }
    
    Shell session
    $ zig build run
    error: UnknownVariant
    (cut)
    

Goodbye is function (sort of)

Type-defined blocks no longer require an is function to be declared.

Type-defined blocks are only ever processed for the type that they're defined in, so it didn't really make any sense to require an is function in them.

Also gone are type-defined tuples, for pretty much the same reason. If type-defined blocks are only ever processed against whatever type they're defined in, there's no reason to allow multiple blocks to be specified in a tuple since the first matching block will always get chosen.

Zig code
const std = @import("std");
const json = @import("json");

const allocator = std.heap.page_allocator;

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

    pub const @"getty.sb" = struct {
        pub fn serialize(v: anytype, ser: anytype) !@TypeOf(ser).Ok {
            var s = try ser.serializeSeq(2);
            const seq = s.seq();

            try seq.serializeElement(v.x);
            try seq.serializeElement(v.y);

            return try seq.end();
        }
    };
};

pub fn main() !void {
    const point = Point{ .x = 1, .y = 2 };

    const slice = try json.toSlice(allocator, point);
    defer allocator.free(slice);

    std.debug.print("{s}\n", .{slice});
}
Shell session
[1,2]

Key allocation

Deserialization of Map keys has always been a pain point for Getty.

Specifically, it was difficult for a visitor to tell whether or not a deserialized key was allocated, and therefore whether or not it should/could be deallocated. Which is important, since knowing the difference would allow us to avoid memory leaks, enable compile-time deserialization of struct values, and unlock performance gains for certain map-like data structures.

Originally, the convention was to simply assume that all deserialized (pointer) map keys are allocated, except in the case of structs. However, that turned out to be super inconsistent, confusing for newcomers, and it didn't even really work in the general case.

Getty 0.3.0 fixes this problem by adding a new method to the getty.de.MapAccess interface, isKeyAllocated. Visitors can simply query this method to determine whether or not a key is allocated. By default, the method returns true if the key type being deserialized into is a pointer. But of course, implementations are free to override this method however they like!

Below is a snippet from Getty JSON's getty.de.MapAccess implementation for struct values. Here, isKeyAllocated returns true only for strings that contain escaped characters.

Zig code
fn StructAccess(comptime D: type) type {
    return struct {
        d: *D,
        is_key_allocated: bool = false, // 👋 By default, keys are not
                                        //    allocated.

        const Self = @This();

        fn isKeyAllocated(self: *Self, comptime _: type) bool {
            return self.is_key_allocated;
        }

        fn nextKeySeed(self: *Self, a: ?Allocator, seed: anytype) Error!?@TypeOf(seed).Value {
            // 👋 Parse token from input data.
            if (try self.d.tokens.next()) |token| {
                switch (token) {
                    .String => |t| {
                        // 👋 Get string from token.
                        const slice = t.slice(self.d.tokens.slice, self.d.tokens.i - 1);

                        // 👋 If the string has escaped characters, set
                        //    is_key_allocated to true since unescaping
                        //    a string requires allocation.
                        self.is_key_allocated = t.escapes == .Some;

                        // 👋 Give the visitor the correct string.
                        return switch (t.escapes) {
                            .None => slice,
                            .Some => try unescapeString(a.?, token.String, slice),
                        };
                    },
                    .ObjectEnd => return null,
                    else => {},
                }
            }

            return error.InvalidType;
        }

        // (cut)
    };
}

// (cut)

Ignoring values

Getty's default deserialization tuple now contains a block for getty.de.Ignored.

By deserializing into this type, visitors can easily skip and ignore deserialized values, which is useful when deserializing aggregate types. For example, you often have to do a final call to nextElement or nextKey to make sure that there are no more remaining elements/entries; getty.de.Ignored is the perfect type to pass in for that final call.

Mandatory errors

To ensure compatibility with Getty's default blocks and to enable visitors to return error.Unsupported (which is needed for deserializeAny), the error sets of serializers and deserializers must now contain getty.ser.Error and getty.de.Error, respectively.

Getty will check for these error sets at compile-time and let you know if you forgot them.

Support

Some new types are now supported by Getty!

Serialization
Deserialization

Other Changes

There are other changes in the Getty 0.3.0 release. You can see the full changelog here.