Zig Is Exciting

Zig is a newer language that is only on version 0.16.0. Despite it not being at version 1.x yet and therefore could have major breaking changes, there is a lot to get excited about with the current implementation of the language. It shows such promise that I’m learning it now and hope for it to become a regular member of my toolbox. Here’s what excites me about it:

Type System Sanity

I’m a very big fan of Programming Language Theory (PL Theory) and of course that means type-systems and how to encode guarantees into the types. Part of that is having the actual type system be sane and detailed enough that you can represent those requirements and prevent users (including yourself) from breaking those requirements. Zig has an advanced metaprogramming technique called comptime that I will discuss later. But part of why comptime works well is that the underlying system it builds on is sane.

Encoding Guarantees

Non-Nullable by default

Yes that’s right. The billion dollar mistake is brought under control with Zig.

In Zig if you want to be able to set something to null, the type has to be declared as ?T.

var b: bool = true;
var nullable_b: ?bool = null;
// These work
nullable_b = b;
b = false;
nullable_b = null;
nullable_b = true;

//// compiler errors:
//// Cannot assign null to type bool
// b = null;
//// Cannot assign ?bool to type bool
// b = nullable_b;

Error unions

Zig believes in being very clear about intention and not having code that can cause effects from afar. A lot of languages use exceptions to denote errors. But exceptions are effects from afar that are often not coded into the type system. And when they are it kinda sucks. See Java’s checked exceptions and how that causes problems with their lambdas.

Instead, Zig has a specialized version of unions that are solely for denoting errors. A type of E!T denotes either an error from the E error set or a value of type T.

The return error set can be inferred by using a type !T. This is mainly useful in functions because writing out the entire error set would be tedious.

If you need to handle every error and can’t determine it at compile time, then you use a type of anyerror!T.

Now whenever you want to use these values in your code, you have to unwrap them and handle the error somehow. More on that later.

ADTs

Zig has support for tagged unions that can act as Algebraic Data Types (ADTs) from the likes of Haskell and OCaml. I find ADTs to be a very sane way to organize complex types. You have a wrapper type that contains other types. The other types can contain other bits of data and unlike with inheritance, the bits of data don’t have to be consistent or shared at all.

const File = union(enum) {
    file: struct {
        name: []const u8,
        content: []const u8,
    },
    dir: struct {
        name: []const u8,
        children: []const File,
    },
    link: struct {
        name: []const u8,
        target: *const File,
    },
    // Branches don't need to have further data attached.
    invalid,

    fn read_file(self: File) ![]const u8 {
        return switch (self) {
            File.file => |f| f.content,
            File.link => |lnk| lnk.target.read_file(),
            File.invalid => error.DoesNotExist,
            File.dir => error.CannotReadADirectory,
        };
    }
};

test "tagged unions" {
    const git_content = "user.email = example@example.com";
    const vimrc = File{ .file = .{
        .name = ".vimrc",
        .content = "set number\nset wrap",
    } };
    const gitconfig = File{ .file = .{
        .name = ".gitconfig",
        .content = git_content,
    } };
    const gitlink = File{ .link = .{
        .name = ".gitlink",
        .target = &gitconfig,
    } };
    const home = File{ .dir = .{
        .name = "~",
        .children = &[_]File{ vimrc, gitconfig, gitlink },
    } };
    const invalid: File = .invalid;

    try expectEqual(error.CannotReadADirectory, home.read_file());
    try expectEqual(error.DoesNotExist, invalid.read_file());
    try expectEqualStrings(git_content, try gitconfig.read_file());
    try expectEqualStrings(git_content, try gitlink.read_file());

    // This should succeed
    _ = try vimrc.read_file();
}

Low-level Integration

Zig is meant to be a replacement for C with modern design and ideas. Part of why C is so prolific is the number of platforms it supports and the low level tools it gives you for dealing with the system. Zig has cross-compilation built-in and supports many platforms. In order to support developers of many platforms, Zig has some cool things built-in.

Arbitrary Width Integers

This is kind of a minor one for me but I know how important it is for people integrating with obscure platforms and systems. Zig’s unsigned and signed integer types have their bitwidth included in them. Need a number that only goes from 0 to 255? That’s a u8. Working on a theoretical platform that stores data based on Fibonacci numbers? Sure you can create an i55 if you need to.

Pointers

As part of being a low-level language, Zig gives you more direct control over the memory used by your data. A common need is to be able to share memory instead of needing to copy it around. Like C, Zig implements this using pointers. Zig’s implementation avoids some problems that C and C++ have.

The main problem it avoids is null pointer dereferences. A pointer to memory location 0 is considered an invalid pointer and trying to read the data at that memory location results in a segfault. But since Zig types are non-nullable by default, that extends to their pointer types. A pointer of type *T can never be null. In order to have a null pointer, you have to use the type ?*T. And since this is an Optional type, you have to deal with the null pointer case instead of blindly dereferencing the pointer and potentially getting bit. As a fun compiler implementation detail, ?*T takes up the same amount of space as *T, it just uses 0 to represent the null case. But the programmer has to deal with the case separately, which is safer.

Miscellaneous

Structs Have Methods

This really isn’t necessary, but I like it for organization purposes. Attaching functions related to a type to that type itself just makes sense to me and makes it easier to understand what that type can do (and/or why it even exists as a separate type).

Language/Type System Integration

Not only does the type system feel sane, the way the language is built around using those types feels sane and makes it easy to use those advanced types while avoiding using them incorrectly.

const vs var declarations

One of the simplest things is that declarations are either const or var. Consts can’t be changed while vars can. This is really common in modern languages (val/var in Kotlin, const/let in Javascript 6+) and really helps avoid errors.

const cx: i32 = 1;
var vx: i32 = 2;

// These work
vx = 42;
vx = cx;
vx = vx + 1;

// compiler errors
//
//// error: cannot assign to constant
// cx = 3;
//
//// error: cannot assign to constant
// cx = vx;

if

Along with handling normal true/false values, if unwraps Optionals and Error Unions:

const x: ?i32 = 42;
if (x) |v| {
    // v has type `i32`, the optional has been unwrapped
    try expectEqual(42, v);
} else {
    // handle null case
}

// 255 is the max value for u8, so this should overflow
const y: error{Overflow}!u8 = std.math.add(u8, 255, 255);
if (y) |v| {
    // v has type `u8`, the error union has been unwrapped
    _ = v;
    // The above is a trick to make the compiler stop complaining us not using v
} else |err| {
    // err has type `error{Overflow}`, an error set of only one possible value
    try expectEqual(error.Overflow, err);
}

while

while knows how to handle expressions that return Optionals and Error Unions, along with the standard bools. This is how Zig implements the Iterator pattern, by returning an Optional from the next() method of iterators.

A while loop unwraps types the same as if expression, but might evaluate the predicate and body multiple times. The else branch is called when an error or null is returned by the predicate and will only run once.

const string: []const u8 = "Hello world!";
// Even though we don't modify it, we have to declare word_iterator as var.
// This is because next() requires a variable reference to the iterator in order to modify its internal state
var word_iterator = std.mem.splitAny(u8, string, " ");
var iterations: u3 = 0;
// next() returns ?[]const u8, so we unwrap it immediately
while (word_iterator.next()) |word| {
    const expected: []const u8 = if (iterations == 0) "Hello" else "world!";
    try expectEqualStrings(expected, word);
    iterations = iterations + 1;
} else {
    // the else case is triggered when the next() returns null
    // i.e. when the iterator has reached the end
    try expectEqual(2, iterations);
}
var iterations: u8 = 0;
// u4 has max value 2^4 - 1 == 15
var i: u4 = 0;
while (std.math.add(u4, i, 4)) |v| {
    // v has type `u4`, the error union has been unwrapped
    i = v;
    iterations = iterations + 1;
} else |err| {
    // err has type `error{Overflow}`
    try expectEqual(error.Overflow, err);
    // 15 / 4 == 3, so it should succesfully run the `while` body 3 times
    try expectEqual(3, iterations);
}

Error Handling

Zig does not allow you to ignore errors and that is great design. Part of why this works well is the tools Zig provides for common patterns.

Bubbling up errors

If you’ve worked in Golang, you’ve become intimately familiar with the dreaded pattern to bubble up errors that you need to use and reuse constantly:

v, err := func();
if (err != nil) {
  return err, nil;
}

Zig has a better version:

const v = try func();

try will either unwrap the value or cause the function to exit earlier by returning the error. This combined with the ability for Zig to infer error sets allows us to compose functions that return different errors and yet still handle them with ease.

fn func(x: i32) !i32 {
    if (x == 0) {
        return error.NoZeroesPlease;
    } else {
        return x - 1;
    }
}

fn func2(x: i32) !i32 {
    if (x == 1) {
        return error.NoOnesPlease;
    } else {
        const v: i32 = try func(x);
        return v - 1;
    }
}

test "try" {
    var valid: i32 = try func(24);
    try expectEqual(23, valid);
    var failure: anyerror!i32 = func(0);
    try expectEqual(error.NoZeroesPlease, failure);

    valid = try func2(24);
    try expectEqual(22, valid);

    failure = func2(1);
    try expectEqual(error.NoOnesPlease, failure);

    failure = func2(0);
    // Note func2 bubbles up the error from func
    try expectEqual(error.NoZeroesPlease, failure);
}

You may have noticed that I’ve been using try a lot in these examples. That’s because expectEqual has return type !void. This means it either returns an unspecified error or doesn’t return anything at all. It is a compiler error to ignore those errors, so we use try to fail the tests when the test early returns with an error.

Quickly Unwrapping

While you can use if to unwrap error unions, there’s some shorthand available.

var v: anyerror!i32 = func2(24);
var valid: i32 = v catch 7;
try expectEqual(22, valid);

v = func2(0);
valid = v catch 7;
try expectEqual(7, valid);

// catch can also run a block that can optionally capture the error
valid = v catch |err| blk: {
    if (err == error.NoOnesPlease) {
        std.process.exit(1);
    } else if (err == error.NoZeroesPlease) {
        // return this value from this block. I'm not a fan of this syntax
        break :blk 8;
    } else {
        // Will bubble up the error
        return err;
    }
};
try expectEqual(8, valid);

Optionals also have an equivalent keyword, orelse.

var v: ?i32 = null;
var valid: i32 = v orelse 8;

try expectEqual(8, valid);

v = 42;
valid = v orelse 8;

try expectEqual(42, valid);

v = null;
valid = v orelse blk: {
    if (valid == 7) {
        return error.IDontLikeSevens;
    } else {
        break :blk 8;
    }
};
try expectEqual(8, valid);

defer

Like Go, Zig has a defer statement to cause things to be run at the end of the current function. This is commonly used to open resources and then defer closing them.

Zig also has an errdefer that only runs if the current function returns an error. This can be used to tear down resources when you’re trying to create an object and it fails partway through.

Since these statements cause things to run at the end of the current function, they are an example of effects from afar that Zig tries to avoid. However this is an acceptable tradeoff because it actually keeps things closer together. In your code you’ll put the defer statements right under where you create the thing that needs cleaning up, keeping things clear and easier to remember that “yes, I have covered closing this file on all return paths”.

switch

switch can be used to easily differentiate between branches of a union (including error sets because they are a union of individual error values)

fn reverse_func2(x: anyerror!i32) !i32 {
    // Note you cannot switch on an error union, you must unwrap it first.
    if (x) |v| {
        return switch (v) {
            3 => error.OkayIDontLikeThreesNow,
            else => |rest| rest + 2,
        };
    } else |err| {
        return switch (err) {
            error.NoZeroesPlease => 0,
            error.NoOnesPlease => 1,
            else => err,
        };
    }
}

test "switch" {
    var input: anyerror!i32 = func2(20);
    var output = reverse_func2(input);
    try expectEqual(20, output);

    input = func2(0);
    output = reverse_func2(input);
    try expectEqual(0, output);

    input = func2(1);
    output = reverse_func2(input);
    try expectEqual(1, output);

    input = func2(5);
    output = reverse_func2(input);
    try expectEqual(error.OkayIDontLikeThreesNow, output);
}

Also as I showed earlier in section on tagged unions, switch is the common way you branch on those types.

Type Inference

Just like you can use !T to infer the error set, in a lot of places you don’t need to put type definitions on your declarations. Zig will infer types, which can help with rapid development or just removing verbosity when the types of things are clear.

Control Flow In Expressions

You can use if and switch as expressions that return a value instead of just as statements, like in C. This means no awkward ternary operators or forward declarations. If you want to assign different values based on different branches, you can easily.

fn clamp(x: i32) u8 {
    const ret: u8 = if (x <= 0) 0 else if (x >= 255) 255 else @intCast(x);
    return ret;
}

test clamp {
    var big: i32 = 89;
    var small: u8 = clamp(big);

    try expectEqual(89, small);

    big = -340;
    small = clamp(big);
    try expectEqual(0, small);

    big = 1_000_000;
    small = clamp(big);

    try expectEqual(255, small);
}

test blocks

I’ve already shown this off a little bit, but Zig builds tests into the language via test blocks. It is normal practice to put your tests right next to what they are testing in the same file. And if you label the block with an identifier instead of a string, those tests will get attached to the generated documentation for that identifier. Just another example of Zig’s philosophy of not having effects from afar. If you break something, the tests are right there.

test blocks can be ran with zig test, but don’t get compiled into the final product when doing an actual build.

Detecting Memory Leaks

Zig’s memory management system, like most low-level languages, allows you to create long-lived values on the heap. Zig’s design for handling this is that it requires you to explicitly pass around allocators that allow the functions you call to use the heap. This means that you can control how the heap is used by the functions that you call and the libraries you use. In C this would require creating a hacked version of libc and praying that none of your dependencies are statically linked against the default system libc.

A wild consequence of this is that the standard library includes std.heap.DebugAllocator, which is intended to be used in tests. The DebugAllocator itself is allocated on the stack, so when the test block concludes and all stack variables are cleaned up, the DebugAllocator is able to report any heap memory that it was responsible for and if any of that heap memory is still claimed.

Let me repeat that: detecting memory leaks is built into the freaking language!

Zig will fail your tests if they have a memory leak, so your top-level allocator in tests should always be DebugAllocator. This is so much more user-friendly than C.

comptime Insanity

So building upon this base of sanity is where Zig really starts to get interesting. This is the concept that got me interested in the language. Everything I’ve talked about before has been things I’ve noticed while trying to explore this crazy concept.

Zig handles metaprogramming by allowing you to evaluate almost anything at compile time by marking it as comptime. What this means is that the compiler will either simplify/remove cases that are not possible and generate specialized versions of functions when given different types or even just different compile-time known values.

Examples of what can be done with this

Generics

All generics in Zig are implemented using comptime. And as a result, most of the standard generic types are implemented in the standard library instead of the language itself. And if you need generics, you can implement them in the same way.

Format Strings

Substituting values into a format string is a function generated based on the compile-time known format string. Passing the wrong number or types of values? That’s a compiler error.

Reflection

You can take a compile-time known type and generate a specialized function based on what it is and what it contains. This can also be a function that returns another type, allowing you to construct new types at compile time. It was super easy for me to write a function that takes a compile-time known type and it either emits a compiler error if the input type is not a function, or the return type of that function. And then you can use that as the return type of other functions. Generated types in Zig can be quite hard to parse when you first look at one, but that’s just the result of the power provided by this system.

Build-specific code

If the predicate to an if or switch is known at compile time, Zig will optimize that control flow block away by only including the parts that are valid for the current compile-time known state. This can be used for your own custom build variables and is also how Zig handles different Operating Systems.

fn osName() []const u8 {
    return switch (@import("builtin").target.os.tag) {
        .linux => "linux",
        .windows => "windows",
        else => |os| @tagName(os),
    };
}

If we compile this target for Linux, we’ll end up with a function that looks like this:

fn osName() []const u8 {
  return "linux";
}

A lot of potential for optimizations to be done at compile time this way.

Optimizing One of my Projects

One idea I had was being able to produce optimized binaries for systems with less resources. I have a program (currently written in Python) that will read a role from an environment variable, read a config file, and then apply the configuration for that role to the current computer. It mostly manages making sure dotfiles are symlinked correctly and setting environment variables for various codebases that might be in different locations on different systems. (ie. cd $notes). I also run this on my Raspberry Pis, but it is quite slow and leads to a noticeable lag when logging into a bash shell, just to set some variables and make sure the configuration is correct. Part of that lag is just reading the YAML file and parsing the configuration for the current role.

But the roles for these devices is always going to be the same! So my idea is to introduce a compile-time variable:

// NOTE: I tried to add the comptime keyword to the type here but the compiler
// told me it was redundant because it already knew the value was known at compile-time
const role_name: ?[]const u8 = null; // TODO: insert compiler magic to get a build variable

When null, the binary will be compiled as normal. It will read a config file, it will support a command line argument to tell you where that config file is, and it will provide another command line argument to override the role. This will run on my normal desktops and also be useful for testing/rapid deployment since I can specify different roles.

But if the role_name is actually set? The build will read the current configuration file and parse it. It will embed the parsed data for the role directly into the binary itself, eliminating the entire step of parsing a file. As a result, the binary won’t provide the command line arguments to set the role or the config file.

This sounds impossible in the Python world, but I’m really confident this can work in Zig. And implementing it should be easy. The code that parses the role data? Theoretically that should be the exact same function that the regular binary uses, just running in comptime instead of at runtime.

And if I understand how Zig builds correctly, the final binary for the Raspberry Pi systems won’t even contain any code from the YAML parsing library, since it is not needed at runtime! Curious to see the size difference in the two binaries!

Controlled Insanity

So this all creates a huge amount of potential for insanity. And some of it does feel insane. But it is built on the sane core of the base language. comptime isn’t like macros in C (which operate directly on the text) or the macros in Lisps (which operates on the abstract syntax tree of the code). You still have to handle errors, you still know when things can be null or not, you can still branch on unions. You’re using the exact same language to write these meta constructs, and if you use them wrong, that’s a compiler error. So it feels like it will be manageable while still being powerful. I need to dive in more, but everything I’ve tried to do so far I’ve been able to figure out by following the compiler errors.

And given that my primary language in Python, which is well-known for being slow due to processing everything at runtime, doing more at compile time and producing something optimized appeals to me.


Afterword: Source Code Used to Verify these Examples

Since testing is built in to the language, I wrote the following zig_is_exciting.zig to verify my examples in this blogpost and capture the actual error messages. It was also a good learning exercise for me to clarify my understanding! Writing this blogpost was very fun for me.

const std = @import("std");

const Allocator = std.mem.Allocator;

const expectEqual = std.testing.expectEqual;
const expectEqualStrings = std.testing.expectEqualStrings;

test "nullable" {
    var b: bool = true;
    var nullable_b: ?bool = null;

    // These work
    nullable_b = b;
    b = false;
    nullable_b = null;
    nullable_b = true;

    //// compiler errors:
    //
    //// error: expected type 'bool', found '@TypeOf(null)'
    // b = null;
    //
    //// error: expected type 'bool', found '?bool'
    // b = nullable_b;
}

const File = union(enum) {
    file: struct {
        name: []const u8,
        content: []const u8,
    },
    dir: struct {
        name: []const u8,
        children: []const File,
    },
    link: struct {
        name: []const u8,
        target: *const File,
    },
    // Branches don't need to have further data attached.
    invalid,

    fn read_file(self: File) ![]const u8 {
        return switch (self) {
            File.file => |f| f.content,
            File.link => |lnk| lnk.target.read_file(),
            File.invalid => error.DoesNotExist,
            File.dir => error.CannotReadADirectory,
        };
    }
};

test "tagged unions" {
    const git_content = "user.email = example@example.com";
    const vimrc = File{ .file = .{
        .name = ".vimrc",
        .content = "set number\nset wrap",
    } };
    const gitconfig = File{ .file = .{
        .name = ".gitconfig",
        .content = git_content,
    } };
    const gitlink = File{ .link = .{
        .name = ".gitlink",
        .target = &gitconfig,
    } };
    const home = File{ .dir = .{
        .name = "~",
        .children = &[_]File{ vimrc, gitconfig, gitlink },
    } };
    const invalid: File = .invalid;

    try expectEqual(error.CannotReadADirectory, home.read_file());
    try expectEqual(error.DoesNotExist, invalid.read_file());
    try expectEqualStrings(git_content, try gitconfig.read_file());
    try expectEqualStrings(git_content, try gitlink.read_file());

    // This should succeed
    _ = try vimrc.read_file();
}

test "pointers" {
    const x: i32 = 7;
    const p: *const i32 = &x;
    try expectEqual(x, p.*);
}

test "const/var" {
    const cx: i32 = 1;
    var vx: i32 = 2;

    // These work
    vx = 42;
    vx = cx;
    vx = vx + 1;

    // compiler errors
    //
    //// error: cannot assign to constant
    // cx = 3;
    //
    //// error: cannot assign to constant
    // cx = vx;
}

test "if" {
    const x: ?i32 = 42;
    if (x) |v| {
        // v has type `i32`, the optional has been unwrapped
        try expectEqual(42, v);
    } else {
        // handle null case
    }

    // 255 is the max value for u8, so this should overflow
    const y: error{Overflow}!u8 = std.math.add(u8, 255, 255);
    if (y) |v| {
        // v has type `u8`, the error union has been unwrapped
        _ = v;
        // The above is a trick to make the compiler stop complaining us not using v
    } else |err| {
        // err has type `error{Overflow}`
        try expectEqual(error.Overflow, err);
    }
}

test "while_error-union" {
    var iterations: u8 = 0;
    // u4 has max value 2^4 - 1 == 15
    var i: u4 = 0;
    while (std.math.add(u4, i, 4)) |v| {
        // v has type `u4`, the error union has been unwrapped
        i = v;
        iterations = iterations + 1;
    } else |err| {
        // err has type `error{Overflow}`
        try expectEqual(error.Overflow, err);
        // 15 / 4 == 3, so it should succesfully run the `while` body 3 times
        try expectEqual(3, iterations);
    }
}

test "while_optional" {
    const string: []const u8 = "Hello world!";
    // Even though we don't modify it, we have to declare word_iterator as var.
    // This is because next() requires a variable reference to the iterator in order to modify its internal state
    var word_iterator = std.mem.splitAny(u8, string, " ");
    var iterations: u3 = 0;
    // next() returns ?[]const u8, so we unwrap it immediately
    while (word_iterator.next()) |word| {
        const expected: []const u8 = if (iterations == 0) "Hello" else "world!";
        try expectEqualStrings(expected, word);
        iterations = iterations + 1;
    } else {
        // the else case is triggered when the next() returns null
        // i.e. when the iterator has reached the end
        try expectEqual(2, iterations);
    }
}

fn func(x: i32) !i32 {
    if (x == 0) {
        return error.NoZeroesPlease;
    } else {
        return x - 1;
    }
}

fn func2(x: i32) !i32 {
    if (x == 1) {
        return error.NoOnesPlease;
    } else {
        const v: i32 = try func(x);
        return v - 1;
    }
}

test "try" {
    var valid: i32 = try func(24);
    try expectEqual(23, valid);
    var failure: anyerror!i32 = func(0);
    try expectEqual(error.NoZeroesPlease, failure);

    valid = try func2(24);
    try expectEqual(22, valid);

    failure = func2(1);
    try expectEqual(error.NoOnesPlease, failure);

    failure = func2(0);
    // Note func2 bubbles up the error from func
    try expectEqual(error.NoZeroesPlease, failure);
}

test "catch" {
    var v: anyerror!i32 = func2(24);
    var valid: i32 = v catch 7;
    try expectEqual(22, valid);

    v = func2(0);
    valid = v catch 7;
    try expectEqual(7, valid);

    // catch can also run a block that can optionally capture the error
    valid = v catch |err| blk: {
        if (err == error.NoOnesPlease) {
            std.process.exit(1);
        } else if (err == error.NoZeroesPlease) {
            // return this value from this block. I'm not a fan of this syntax
            break :blk 8;
        } else {
            // Will bubble up the error
            return err;
        }
    };
    try expectEqual(8, valid);
}

test "orelse" {
    var v: ?i32 = null;
    var valid: i32 = v orelse 8;

    try expectEqual(8, valid);

    v = 42;
    valid = v orelse 8;

    try expectEqual(42, valid);

    v = null;
    valid = v orelse blk: {
        if (valid == 7) {
            return error.IDontLikeSevens;
        } else {
            break :blk 8;
        }
    };
    try expectEqual(8, valid);
}

fn reverse_func2(x: anyerror!i32) !i32 {
    if (x) |v| {
        return switch (v) {
            3 => error.OkayIDontLikeThreesNow,
            else => |rest| rest + 2,
        };
    } else |err| {
        return switch (err) {
            error.NoZeroesPlease => 0,
            error.NoOnesPlease => 1,
            else => err,
        };
    }
}

test "switch" {
    var input: anyerror!i32 = func2(20);
    var output = reverse_func2(input);
    try expectEqual(20, output);

    input = func2(0);
    output = reverse_func2(input);
    try expectEqual(0, output);

    input = func2(1);
    output = reverse_func2(input);
    try expectEqual(1, output);

    input = func2(5);
    output = reverse_func2(input);
    try expectEqual(error.OkayIDontLikeThreesNow, output);
}

fn clamp(x: i32) u8 {
    const ret: u8 = if (x <= 0) 0 else if (x >= 255) 255 else @intCast(x);
    return ret;
}

test clamp {
    var big: i32 = 89;
    var small: u8 = clamp(big);

    try expectEqual(89, small);

    big = -340;
    small = clamp(big);
    try expectEqual(0, small);

    big = 1_000_000;
    small = clamp(big);

    try expectEqual(255, small);
}

fn osName() []const u8 {
    return switch (@import("builtin").target.os.tag) {
        .linux => "linux",
        .windows => "windows",
        else => |os| @tagName(os),
    };
}

test "os" {
    try expectEqual("linux", osName());
}

const role_name: ?[]const u8 = null;