What is the recommended way to create custom error in Zig?

Hello, I found that I can do 3 ways to create custom error

Number 1:

const ValidationError = error{
    ValueTooSmall,
    ValueTooLarge,
};

Number 2:

const ValidationError = struct {
    message: []const u8,
};

Number 3:

const ValidationError = union(enum) {
    TooSmall: []const u8,
    TooLarge: []const u8,
};

Which one is the recommended way?

1 Like

Way #1 is the only way that will work with Try/Catch syntax. I’d say it’s the preferred way. However sometimes it’s not sufficient when you want to deal with reporting information. In that case I’ve used error data structs as a side channel for building out messages.

I prefer to think about separating error handling and error reporting as two, often related, but different tasks. Zig errors are more about guiding the control flow.

Also there is a ā€œfourthā€ way to create an error:

return error.ValueTooSmall
9 Likes

The caller probably needs to know that a validation error has occurred, so returning a true Zig error for that (#1) is needed. True errors are used in Zig to perform control flow in the caller.

For just reporting an error message, you may not need to return the message field to the caller. You could log it immediately instead, for example.

But if the caller needs the message or other information about the error, then it can be returned in a separate ā€œoutā€ parameter, in addition to returning the error. The caller would catch the error, switch on the error type, and then use the message returned in this extra parameter. For example, this extra parameter could be type ?*ValidationInfo, where ValidationInfo contains the message. By making it an optional pointer, the caller can pass null if the additional info is not needed.

5 Likes

How about my custom error here? Is it good or need what improvement?

const std = @import("std");

fn Error(comptime pairs: anytype) type {
    return struct {
        const Val = blk: {
            var fields: [pairs.len]std.builtin.Type.Error = undefined;
            for (pairs, 0..) |p, i| fields[i] = .{ .name = p[0] };
            break :blk @Type(.{ .error_set = &fields });
        };

        const messages = blk: {
            var msgs: [pairs.len][]const u8 = undefined;
            for (pairs, 0..) |p, i| msgs[i] = p[1];
            const final = msgs;
            break :blk final;
        };

        fn getMsg(err: anyerror) []const u8 {
            inline for (pairs, 0..) |p, i| {
                if (err == @field(Val, p[0])) return messages[i];
            }
            return "Unknown Error";
        }
        
        fn tryCatch(res: anytype, context: ?[]const u8) @typeInfo(@TypeOf(res)).error_union.payload {
            if (res) |val| {
                return val;
            } else |err| {
                if (context) |ctx| {
                    std.debug.print("![{s}] {s}\n", .{ ctx, getMsg(err) });
                } else {
                    std.debug.print("! {s}\n", .{getMsg(err)});
                }
                return std.mem.zeroes(@typeInfo(@TypeOf(res)).error_union.payload);
            }
        }
    };
}

fn Min(comptime T: type, comptime limit: T, comptime err: anyerror) type {
    return struct {
        pub fn validate(val: T) ?anyerror {
            if (val < limit) return err;
            return null;
        }
    };
}

fn Max(comptime T: type, comptime limit: T, comptime err: anyerror) type {
    return struct {
        pub fn validate(val: T) ?anyerror {
            if (val > limit) return err;
            return null;
        }
    };
}

fn Validated(comptime T: type, comptime Validators: anytype) type {
    return struct {
        value: T,
        const Self = @This();
        pub fn new(val: T) anyerror!Self {
            inline for (Validators) |V| {
                if (V.validate(val)) |err| return err;
            }
            return Self{ .value = val };
        }
        pub fn take(self: Self) T { return self.value; }
    };
}


const Err = Error(.{
    .{ "Underage", "Access denied: Age is below the minimum requirement" },
    .{ "TooOld", "Access denied: Age exceeds the maximum limit" },
});

const Age = Validated(i32, .{
    Min(i32, 18, Err.Val.Underage),
    Max(i32, 60, Err.Val.TooOld),
});

const User = struct {
    age: Age
};

pub fn main() !void {

    _ = User {
        .age = Err.tryCatch(Age.new(10), null)
    };

}
1 Like

what do you like about it? what is it trying to accomplish? does it do that? i think if you had clear answers to those questions, our feedback might be less useful.

It’s great that you’ve learned how to use comptime, anytype and anyerror. You’ll need that skill.

For this use case, I wonder whether anytype and anyerror are really needed. It is best to avoid them, if possible, because they reduce code readability.

I see in this use case that the specific error is not needed by the caller (in main), since the error details are logged before returning. What will the caller do when an error is returned, in a real application? Will the application need to switch on the error code? If so, how would it do that?

What I want to accomplish is I can generate error messages that can be reused in other code/file and can add additional context if needed. I’m not sure if it accomplish it fully yet ;(

I’m still learning metaprogramming in Zig ;v

I’m myself not sure if that is the correct approach, because I’m still new in Zig. Here is what I’m trying to do (please correct whether anytype, anyerror, etc is needed in this case)

I’m trying to create prototype for struct field validation library, where the rule is centralized in the declaration. The caller doesn’t know what is the validation rule. It is to avoid potential inconsistency when there are many callers. The value is protected under Validated struct to prevent accidental mutate without going through the validation rules. The user can specify specific error message for each validation, then the error will be propagated until it eventually be consumed in a logging and/or a http response. The tryCatch method is just my syntatic sugar to print the error, but not part of the library, the real usage will just place try keyword to propagate the error. The library will expand to email validation, string length min and max validation, is a string contains certain character like it must contains combination of char number and symbol, no whitespace validation, sanitize or block certain character, datetime validation like the datetime can not be the past, etc

Edit : The tryCatch method will still be used by removing the println, as a syntatic sugar to add dynamic error message if the user need to add dynamic message

What do you think about that?

In general I also like to refrain from coupling error handling and reporting together to much. Also zig has some nice error return traces so it is often easier to just use them. See the language reference.

The problem with your current approach (with the tryCatch) is that this information is lost because you just return some zeroed memory on error. This could be the right call depending on what you’re doing but I would just let the caller handle the error as they want to.

If you want to pursue this further you could also do something like this. Of course just to illustrate

const std = @import("std");

const MyErrors = error{ foo, bar, baz };
const ErrorTuple = struct { MyErrors, []const u8 };
// or if you really want to
// const ErrorTuple = struct { anyerror, []const u8 };

const error_tuples: []const ErrorTuple = &.{
    .{ MyErrors.foo, "got a foo" },
    .{ MyErrors.bar, "go to a bar" },
    .{ MyErrors.baz, "drink a baz" },
};
var error_map: std.AutoHashMapUnmanaged(MyErrors, []const u8) = .empty;

fn initErrorMap(allocator: std.mem.Allocator) !void {
    try error_map.ensureTotalCapacity(allocator, error_tuples.len);
    errdefer comptime unreachable;
    for (error_tuples) |t| {
        error_map.putAssumeCapacity(t[0], t[1]);
    }
}

// maybe change argument and return type or something
fn logError(err: MyErrors) void {
    std.debug.print("got error {}: {s}\n", .{ err, error_map.get(err) orelse "" });
}

pub fn main() !void {
    try initErrorMap(std.heap.page_allocator);
    someFunc(0) catch |err| logError(err);
    someFunc(1) catch |err| logError(err);
    someFunc(42) catch |err| logError(err);
}

fn someFunc(a: u64) !void {
    switch (a) {
        0 => return MyErrors.foo,
        1...3 => return MyErrors.bar,
        else => return MyErrors.baz,
    }
}

You could also add other parameters to logError to print something different depending on further information. But I personally wouldn’t do that because you tangle everything together into one big ball of mud.

Usually when you want to log a warning, or an error, or whatever, you have more information and in particular more context at the callsite. Then you can write a good message for the user with some more information which is nicer and could, for instance, maybe help you find bugs easier.

1 Like

#1 is the idiomatic way. It’s the only one that works with try, catch, errdefer, and error unions (!T).

#2 and #3 are just data, useful for carrying extra context, but they’re not error sets.

Ho! What’s this? Can we ā€˜create’ errors on the fly?

Edit: like in ā€œi am too lazy to declare this specific local error globallyā€?

1 Like
const err = error.FileNotFound;

This is equivalent to:

const err = (error{FileNotFound}).FileNotFound;

i don’t understand errors and errorsets too well.
And this looks even more obscure to me.
const err = (error{FileNotFound}).FileNotFound;
Now I really don’t know anymore what is what. But I am tired…

I was just wondering about that statement earlier. It is indeedpossible I saw…

fn hello() !u8 {
    return error.only_specified_here;
}

The short answer is Yes. error.FileNotFound creates an error set with one error, FileNotFound, and then it returns that one error.

You use an error set when declaring a function return type, since an error union is a union of an error set and another value. You need individual errors when you return them or switch on them. The doc explains it in more detail when you feel like studying.

The key value of the first approach lies in its ability to trigger errdefer when returning. If you believe that in your code there are critical states that need to be released or rolled back when a specific error occurs, then this error must be defined using an error union.

zigs errors are meant to fulfil what was manually done in c with integer error codes. That is to say, zigs error system is for control flow, for code reacting to errors.

If you want/need to propagate other information, you should use a separate mechanism. But you should avoid replacing zigs error system, as it provides great ergonomics for dealing with errors. Both can exist together and compliment each other.

These two blog posts are a great start to explore these concepts: