JS-like errors in Zig

I’m trying to create a userland error type that’s somwhat similar to JavaScript’s errors: the error may hold its own data as well as have a cause (a child error). The memory footprint isn’t a concern, although in an attempt to simplify memory ownershp, the outmost error is assumed to own the memory and deiniting it is expected to deinit all child errors.

I’m new to Zig, and with what I know so far, I tried putting together the following error type:

fn Error(Data: type, Child: type) type {
    return struct {
        const Self = @This();

        name: []const u8,
        data: *Data,
        cause: ?*Child,
        _is_error: bool = true,

        gpa_pointer: *GeneralPurposeAllocator(.{}),
        allocator: std.mem.Allocator,

        pub fn init(
            parent_allocator: Allocator,
            name: []const u8,
            data: Data,
            child: ?Child,
        ) !Self {
            const gpa_pointer = try parent_allocator.create(
                GeneralPurposeAllocator(.{}),
            );
            gpa_pointer.* = .init;
            const allocator = gpa_pointer.allocator();

            const data_ptr = try allocator.create(Data);
            data_ptr.* = data;

            return Self{
                .name = name,
                .data = data_ptr,
                .cause = if (child) |c| blk: {
                    const child_ptr = try allocator.create(Child);
                    child_ptr.* = c;
                    break :blk child_ptr;
                } else null,
                .gpa_pointer = gpa_pointer,
                .allocator = allocator,
            };
        }

        pub fn deinit(self: *Self, parent_allocator: Allocator) void {
            const is_error = blk: {
                var is_error = false;
                switch (@typeInfo(self.data)) {
                    std.builtin.Type.@"struct" => |s| {
                        inline for (s.fields) |field| {
                            if (std.mem.eql(
                                u8,
                                field.name,
                                "_is_error",
                            )) {
                                is_error = true;
                            }
                        }
                    },
                    else => {
                        std.debug.print("no struct\n", .{});
                    },
                }
                break :blk is_error;
            };

            if (is_error) {
                self.data.deinit(parent_allocator);
            }

            self.allocator.destroy(self.data);
            parent_allocator.destroy(self.gpa_pointer);
        }
    };
}

The code doesn’t compile. Within the deinit function, the argument to switch produces an error:

error: unable to resolve comptime value
                switch (@typeInfo(self.data)) {
                                  ~~~~^~~~~
note: types must be comptime-known

How to fix this error?

Overall, is there a better way of achieving having errors with custom data that keep track of what caused them?

You want @typeInfo(@TypeOf(self.data)) I think, or better @typeInfo(Data).

2 Likes

I must say I don’t really understand what you’re doing with all these allocators so maybe I’m wrong. But all this mixing allocator implementation and interface, storing in fields, this is a code smell.

If you want you error implementation to be reusable it should just take an allocator interface with init and deinit, or in a managed fashion with init and then store it in a field. I don’t really understand what’s going on with allocating an allocator implementation in a function…

2 Likes

Thanks @Dok8tavo, your suggestion fixed the issue, and helped identify a bug on that line: the type of Cause is what should be queried,

switch (@typeInfo(Cause)) {

It may very well be a code smell, I’m only getting started with Zig. What I’m trying to achieve is to store data within the error, and have the error also capture a cause (another error) if need be. The gymnastics with the allocators is to make the allocators remain valid after the init function returns. The patterns was copied from this post.

The deinit method now looks like:

pub fn deinit(self: *Self, parent_allocator: Allocator) void {
    const is_error_cause = blk: {
        var is_error = false;
        switch (@typeInfo(Cause)) {
            std.builtin.Type.@"struct" => |s| {
                inline for (s.fields) |field| {
                    if (std.mem.eql(
                        u8,
                        field.name,
                        "_is_error",
                    )) {
                        is_error = true;
                    }
                }
            },
            else => {
                std.debug.print("no struct\n", .{});
            },
        }
        break :blk is_error;
    };

    if (is_error_cause) {
        const error_cause = self.cause.?;
        error_cause.*.deinit(parent_allocator);
        self.allocator.destroy(error_cause);
    }

    self.allocator.destroy(self.data);
    parent_allocator.destroy(self.gpa_pointer);
}

which brings with it a new error:

error: no field or member function named 'deinit' in 'void'
                error_cause.*.deinit(parent_allocator);
                ~~~~~~~~~~~~~^~~~~~~

What’s making the error_cause type be void? It’s enclosed in an if block in an attemt ensure the deinit method is only called on error causes.

Yeah, about this pattern, I’m not sure it makes sense in your case. You don’t even use the arena allocator. Forget about the gpa stuff, just take an allocator in init, and either keep it in the allocator field or take in back in deinit. Keep things simple.

I also want to highlight that this line:

std.debug.print("no struct\n", .{});

Uses the stderr, so it’s called at runtime. This is a mistake in my opinion because the switch is on @typeInfo(Cause) which is comptime. Sure it could work, but now there’s this weird case where is_error_cause is runtime too, so error_cause.*.deinit will be analyzed whether Cause is a struct or not.

Also I’d rewrite this like that:

const is_error = comptime switch (@typeInfo(Cause)) {
    .@"struct" => |struct_info| !struct_info.is_tuple and
        @hasDecl(Cause, "is_error") and
        @TypeOf(Cause.is_error) == bool and
        Cause.is_error,
    .@"union", .@"enum", .@"opaque" => 
        @hasDecl(Cause, "is_error") and
        @TypeOf(Cause.is_error) == bool and
        Cause.is_error,
    else => false,
};

It doesn’t cover perfectly all cases but I guess it’s fine. And it’s comptime known, if the Cause.is_error is a global variable, you’ll get a compile error. It should not trigger the problem with void.

I think making “is_error” instead of “_is_error” and into a boolean makes the interface requirement cleaner. I didn’t keep it a field because then it’d force the user to add data into their struct, which can have a runtime cost. A declaration is basically free, aside from binary size.

Tuples can’t contain declarations:

temp30.zig:7:5: error: tuple declarations cannot contain declarations
    const is_error:bool = true;
    ^~~~~~~~~~~~~~~~~~~~~~~~~~

So this should be equivalent:

const is_error = comptime switch (@typeInfo(Cause)) {
    .@"struct", .@"union", .@"enum", .@"opaque" => 
        @hasDecl(Cause, "is_error") and
        @TypeOf(Cause.is_error) == bool and
        Cause.is_error,
    else => false,
};

because the @hasDecl will always return false for tuples.

I put it there because I thought it would be a compile error. I didn’t try though.

You can switch on types directly:

fn switchOnType(T: type) u8 {
    return switch (T) {
        u16 => 'a',
        i16 => 'b',
        []const u8 => 'c',
        else => @compileError("bad type"),
    };
}

test "compile time switch on type" {
    try expectEqual('b', switchOnType(i16));
}
2 Likes

I don’t quite get the suggestions for checking whether Cause is of type other than a struct? As far as I can tell, it will never be, so only checking for .@"struct", or else it’s definitely not an Error type.

@Dok8tavo I also didn’t get the suggestions on the allocators:

You don’t even use the arena allocator

What does this mean? I’m using the GPA allocator. And didn’t quite understand the allocator optimizations you’re suggesting. Some sample code would go a long way in helping me understand.

In any case, leaving below the final working result, which achieves, as per my criteria, Error behavior close enough to JS’s errors

  • Errors can be nested
  • Errors can have their own payload data
  • Memory safe: deiniting the outermost error recursively cleans up all nested errors.

Open to suggestions if this abstraction can be further improved.

const std = @import("std");
const Allocator = std.mem.Allocator;
const GeneralPurposeAllocator = std.heap.GeneralPurposeAllocator;

fn Error(comptime name: []const u8, Data: type, Cause: type) type {
    return struct {
        const Self = @This();

        name: []const u8 = name,
        data: *Data,
        cause: ?*Cause,
        _is_error: bool = true,

        gpa_pointer: *GeneralPurposeAllocator(.{}),
        allocator: std.mem.Allocator,

        pub fn init(
            parent_allocator: Allocator,
            data: Data,
            child: ?Cause,
        ) !Self {
            const gpa_pointer = try parent_allocator.create(
                GeneralPurposeAllocator(.{}),
            );
            gpa_pointer.* = .init;
            const allocator = gpa_pointer.allocator();

            const data_ptr = try allocator.create(Data);
            data_ptr.* = data;

            return Self{
                // .name = name,
                .data = data_ptr,
                .cause = if (child) |c| blk: {
                    const child_ptr = try allocator.create(Cause);
                    child_ptr.* = c;
                    break :blk child_ptr;
                } else null,
                .gpa_pointer = gpa_pointer,
                .allocator = allocator,
            };
        }

        pub fn deinit(self: *Self, parent_allocator: Allocator) void {
            const is_error_cause = blk: {
                switch (@typeInfo(Cause)) {
                    std.builtin.Type.@"struct" => |s| {
                        inline for (s.fields) |field| {
                            if (std.mem.eql(
                                u8,
                                field.name,
                                "_is_error",
                            )) {
                                break :blk true;
                            }
                        }
                        break :blk false;
                    },
                    else => {
                        break :blk false;
                    },
                }
            };

            if (is_error_cause) {
                const error_cause = self.cause.?;
                error_cause.*.deinit(parent_allocator);
                self.allocator.destroy(error_cause);
            }

            self.allocator.destroy(self.data);
            parent_allocator.destroy(self.gpa_pointer);
        }
    };
}

const Location = struct { line: u8, col: u8, char: u8 };
const Error1 = Error("Unexpected token", Location, void);
const Error2 = Error("Parsing failed", void, Error1);

pub fn someFunc1(allocator: Allocator) Error1 {
    return Error1.init(
        allocator,
        .{
            .line = 8,
            .col = 25,
            .char = '$',
        },
        null,
    ) catch @panic("panic 1");
}

pub fn someFunc2(allocator: Allocator) Error2 {
    const e = someFunc1(allocator);
    return Error2.init(
        allocator,
        {},
        e,
    ) catch @panic("panic 2");
}

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

    var n2 = someFunc2(allocator);
    defer n2.deinit(allocator);

    std.debug.print("{s}\n", .{n2.name});
    std.debug.print("{s}\n", .{n2.cause.?.name});
    std.debug.print("{any}\n", .{n2.cause.?.data});
}

The output to this example program is:

Parsing failed
Unexpected token
main.Location{ .line = 8, .col = 25, .char = 36 }

Why do you recursively create the gpa allocators?

It doesn’t look like you actually use that to check for memory leaks in a smaller scope, which is the only use-case I could imagine.
Otherwise you could just use the passed in parent_allocator.

I think it would be easier to answer, if you described what behavior you actually want from your error type, because I currently find it difficult to reverse engineer from the code that you provided, what your intent is, which parts are essential and what parts may be based on some misconception.

My personal favorite would be to simplify it even more, create an ArenaAllocator in main get the allocator from that and pass it into the error functions, than somewhere where it makes sense in the program call the reset or deinit function of the arena, freeing all the memory that was used by the errors.

const std = @import("std");
const Allocator = std.mem.Allocator;
const GeneralPurposeAllocator = std.heap.GeneralPurposeAllocator;

fn createDeepCopy(allocator:Allocator, data: anytype) !*@TypeOf(data) {
    // TODO add a compile error if data that is expected to be changing at runtime can't be copied deeply
    // because that would mean that our error data could be broken if the actual owner of the data frees it
    // or changes it
    const data_ptr = try allocator.create(@TypeOf(data));
    data_ptr.* = data;
    return data_ptr;
}

fn Error(comptime name: []const u8, Data: type, Cause: type) type {
    return struct {
        pub const is_error: bool = true;
        const Self = @This();

        // why is name a field instead of a declaration?
        // it only makes sense as field if you actually want to change the name at runtime
        // and if you want to change it then somebody needs to manage the memory if the name was created at runtime
        name: []const u8 = name,
        data: *Data,
        cause: ?*Cause,

        pub fn init(
            allocator: Allocator,
            data: Data,
            child: ?Cause,
        ) !Self {
            return Self{
                .data = try createDeepCopy(allocator, data),
                .cause = if (child) |c| try createDeepCopy(allocator, c) else null,
            };
        }

        pub fn deinit(self: *Self, allocator: Allocator) void { // pass allocator to deinit, that way we don't have to store it
            const cause_is_error = comptime switch (@typeInfo(Cause)) {
                .@"struct" =>
                    @hasDecl(Cause, "is_error") and
                    @TypeOf(Cause.is_error) == bool and
                    Cause.is_error,
                else => false,
            };

            if (cause_is_error) {
                if(self.cause) |err| { // use if to unpack optionals
                    err.deinit(allocator); // you can call methods on pointers (one level of indirection only)
                    allocator.destroy(err);
                }
            }
            allocator.destroy(self.data);
        }
    };
}

const Location = struct { line: u8, col: u8, char: u8 };
const Error1 = Error("Unexpected token", Location, void);
const Error2 = Error("Parsing failed", void, Error1);

pub fn someFunc1(allocator: Allocator) Error1 {
    return Error1.init(
        allocator,
        .{
            .line = 8,
            .col = 25,
            .char = '$',
        },
        null,
    ) catch @panic("panic 1");
}

pub fn someFunc2(allocator: Allocator) Error2 {
    const e = someFunc1(allocator);
    return Error2.init(
        allocator,
        {},
        e,
    ) catch @panic("panic 2");
}

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

    var n2 = someFunc2(allocator);
    defer n2.deinit(allocator);

    std.debug.print("{s}\n", .{n2.name});
    std.debug.print("{s}\n", .{n2.cause.?.name});
    std.debug.print("{any}\n", .{n2.cause.?.data});
}

The above is just to show that the code can look simpler and still produce the same output, with an arena it would look even simpler.

However I want to take a break here and ask why do you want to do things this way?


From my experience it is not a good idea to shoehorn a specific way we expect some other language to work, into a new language that works differently, or said more simply, what works well in Javascript, may be a poor fit in Zig.

For Zig I would suggest that you familiarize yourself with the Diagnostic pattern:

This is also an interesting related topic (I haven’t tried that approach in practice so I am not completely sure about it yet, but it looks promising):


Further, I think you have some misconceptions about how Zig works, but I am not entirely sure about what, it definitely seems like there is some confusion about allocators.

I would recommend you to go through https://ziglings.org/ from start to finish, so that you have a solid foundation for how things work, it certainly helped me grasp a lot of the details of how Zig works, quickly.

Also feel free to ask more questions or clarify your goals more, so that we can better understand how to help you.

3 Likes

It would’ve allowed other error implementations to work together with yours. But if you need only results of your Error function to be valid then it doesn’t work as intended.

I suggest you expose the arguments given to Error so you can reify it and check for equality:

pub fn Error(name: []const u8, Data: type, Cause: type) type {
    return struct {
        ...
        pub const params = .{
            name, Data, Cause, 
        };
        ... 
    };
} 

Then you can go:

const is_error = switch (@typeOf(Cause)) {
    . @"struct" =>
        @hasDecl(Cause, "params") and
        @TypeOf(Cause.params) == @TypeOf(Self.params) and
        Cause == Error(Cause.params[0], Cause.params[1], Cause.params[2]),
    else => false, 
};

Now it must be a type that’s returned from Error. Even if another module makes a perfect copy of yours, they won’t work together.

On everything else, I’m with @Sze, I’m not sure what’s the benefit of this pattern over diagnostics, and I think there’s some misunderstanding happening on allocators because storing an interface common to all your errors, but each error carrying its own implementation instance makes no sense to me.

There are things that don’t understand because I’m not familiar with the way JS errors work for sure. But this allocator thing can’t be right.

2 Likes

Thanks, much appreciated!

From my experience it is not a good idea to shoehorn a specific way we expect some other language to work

You’re absolutely right. Mostly using this effort as an exercise to learn about Zig (allocators, comptime, etc). And thanks for the resources for error handling, will take a look.

1 Like