Shalitha Suranga Programmer | Author of Neutralino.js | Technical Writer

Getting started with the Zig programming language

14 min read 4008 105

Getting started with the Zig programming language

Computers can only understand hardware-dependent, instruction-based, low-level languages known as machine languages that consist of binary numbers (0 and 1, a.k.a., low- and high-voltage levels). Assembly language abstractions help developers reduce the extreme complexity of the machine code, but are also very low-level, platform-dependent languages that map 0 and 1 patterns to readable characters, and it aren’t necessarily human-friendly.

This is why we typically use general-purpose, human-friendly languages like C, C++, Python, Rust, etc., for low-level programming. Some programming languages come as alternatives to existing matured ones. For example, Rust is an alternative to C++, and Kotlin to Java.

This is where Zig comes in for C. In this article, I will help you get started with Zig programming by explaining the theoretical aspects and providing practical examples.

Jump ahead:

What is the Zig programming language?

Zig is a minimal open-source, fully-featured systems programming language presented as a friendlier alternative to C. It has a minimal Rust-like syntax but maintains C’s simplicity.

The goal of Zig is to solve issues that C developers often face using a futuristic approach with a new C-like, Rust-syntax-influenced grammar. It offers a productive C-interop solution to let C developers migrate their C codebases to Zig incrementally.

Zig is not just a language — it’s a complete, fully-featured toolchain, meaning you can use Zig to create, develop, test, and build programs/libraries without third-party build automation tools. Zig toolchain can also cross-compile C/C++ projects, so you can productively use Zig toolchain to build your existing C/C++ projects. Zig is designed as a low-level, hardware-friendly language for system programming, but its productive, developer-friendly syntax and features make it even more suitable for building any modern software system.

The Zig project was initially started by Andrew Kelley and is now being maintained by the Zig Software Foundation (ZSF). Zig competes with C by offering a complete system programming solution with a productive C-interop.

Highlighted features of Zig

Zig strives to become a better-C language not only for low-level system programming but for developing general-purpose software systems with the following highlighted features:

Simplicity-first design

A human-friendly, modern language’s goal is to offer a well-designed language syntax that is not atomic as Assembly. If the language’s abstraction is too close to Assembly, the developer may have to write lengthy code. On the other hand, when a language is abstracted to become close to human-readable, it may be too far away from the hardware and may not be suitable for system programming requirements.

Zig offers a lightweight, Rust-like syntax with most features that C offers. It doesn’t offer the complex feature set and syntax that Rust and C++ have, but offers a simplicity-first developer environment like Go.

Performance and security

Performance and security are crucial factors to check out before selecting a specific programming language. The performance of a particular language typically depends on the performance of its standard library, core runtime features, and the quality of the binary generated by the compiler. Meanwhile, a secure design implements boundary checks, overflow handling, and memory scopes, and helps developers reduce critical security vulnerabilities.

The Zig build system offers four build modes that developers can use based on their performance and security requirements. Zig can also understand variable overflows at compile time.

Moreover, it can generate optimized binaries with runtime security checks, as can Rust, and super-lightweight binaries without runtime security checks, as can C. The Zig official documentation claims that Zig is theoretically faster than C due to its LLVM-based optimizations and improved undefined behavior!

A complete system programming solution

Most programming languages have one or more standard compilers and standard library implementations. For example, you can compile C with:

  • GNU C
  • Apple Clang
  • MSVC compilers with libc, BSD-libc, and Microsoft C runtimes

But these two components are not enough for modern system programming requirements. Programmers often need the guardrails that build tools, package managers, and cross-compiling tools provide.



As a result, build tools like CMake, Ninja, Meson, and package managers like Conan became popular in the C ecosystem. Modern languages like Go and Rust offer official inbuilt package managers, build tools/APIs, cross-compilation support, and test runners.

Similarly, Zig has an inbuilt package manager, a build system API, cross-compilation support, and a test runner. This improves Zig’s chances of becoming a better C because it solves critical system programming issues that C (and C++) developers face. From a language design perspective, Zig offers all the features that low-level C developers can expect from a productive C-interop, so C programmers can incrementally migrate their systems to modern Zig without re-writing their entire legacy codebase.

Why you should learn Zig

There are thousands of programming languages, but only a fraction of them could become popular among the developer community. More programming languages will still come in the future, with various features aimed at replacing existing languages.

We don’t need to learn all these languages. But, if a new language comes with a promising future and provides strong, valid, and proven arguments for why it should be used as an alternative to an existing language, it’s undoubtedly better to learn its goals and internal design concepts than ignore it entirely. Go 1.0 was released in 2012 as a new minimal language, and it is now a dependency in major tech companies. In the 1990s, Python was a new, experimental scripting language, but now the digital world depends on it.

Similarly, Zig initially appeared in 2016, published its first pre-release in 2017, and has demonstrated its ability as a modern replacement for C. Zig even offered a complete system programming toolchain with a few years of active development and established a promising future. Given its similarity to and interoperability with C, it can also open you up to a wider array of development opportunities in AI development, game development, and more.

Learning Zig not only adds a promising C-adjacent language to your skillset, but it also improves your knowledge about system programming thanks to its clever, performance-security-balanced design.

Who uses Zig?

At the time of publication, the following popular open-source projects and tech companies use the Zig language and its toolchain:

Projects

Project name Description Source
Bun Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one GitHub
Mach Mach is a game engine & graphics toolkit for the future. GitHub

Companies

Company name Usecase Reference
Uber Uber used the Zig C++ compiler to run Uber services on arm64 hardware via the Hermetic CC toolchain (contains Zig codes). Uber blog
TigerBeetle TigerBeetle offers a financial accounting database written in Zig GitHub

Getting started with Zig

Now that you know about Zig and why it was introduced, let’s get started developing with Zig practically by learning the language syntax, concepts, and features.

Setting up the developer environment

As with any other open-source project, it’s possible to download Zig from the official website or build from the source, but the easiest and most modern way is to install it from your system package manager. I’ve installed the Zig developer toolchain with the following Snap command (run with sudo) on Ubuntu:

snap install zig --beta --classic

See the official installation guide for more information about Zig installation methods.

After installing Zig, verify your installation status by entering zig on your terminal:

Running the zig command in an Ubuntu terminal

Hello, Zig!

We have a working version of the Zig toolchain, so let’s start writing programs in Zig. We’ll write a small Hello, World!-type program to understand the basic source code structure with toolchain basics.

Create a new file named hello.zig and add the following source code:

const std = @import("std");

pub fn main() void {
    std.debug.print("Hello Zig!\n", .{});
}

Here, we import the standard library and load its reference to the std constant in the first line. Then, the main public function, which returns nothing (void), uses the print function to print a debug message on the terminal.

Run the above code with the following command:

zig run hello.zig

You will see Hello Zig! in your terminal. The print function offers a similar API to C’s printf function and lets us use formatted strings via the second parameter:

const std = @import("std");

pub fn main() void {
    std.debug.print("Hello {s}! {d}\n", .{"Zig", 2023}); // Hello Zig! 2023
}

You can also omit the format types for atomics as follows:

std.debug.print("Hello {}\n", .{2023}); // Hello 2023

Compiling a Zig binary

Creating a binary is a mandatory requirement for any software release. Zig lets you cross-compile binaries with four build configurations based on performance and security requirements.

Create a binary for your Hello, World! program:

zig build-exe --name hello-bin hello.zig

The above command generates a debug binary named hello-bin with the default Debug build configuration, so we can execute it via ./hello-bin as follows:

Running a binary compiled with Zig in the terminal

You can use the other three build configurations with the -O {mode} flag:

Compiler flag Runtime safety checks Optimizations
-O Debug Yes No
-O ReleaseSafe Yes Yes (speed)
-O ReleaseSmall No Yes (size)

Like GNU C, Zig generates a binary for the current target (CPU and OS) by default, so the above commands generated x86 Linux binaries for me on my computer. Also, it’s possible to cross-compile binaries with the -target flag.

For example, the following command cross-compile a .exe file for x64 Windows systems:

zig build-exe -target x86_64-windows --name hello-bin.exe hello.zig

Compiling Zig binaries with the builder API

Zig’s compilation command-line interface works the same as GNU C with command-line flags, so we may often need to use CLI flags repetitively for each compilation attempt. In C, we typically solve this problem by writing a shell script for the build process or using a fully-featured build system like CMake.

Build systems let you store compiler flags in a configuration file and even add custom build steps. Zig exposes an inbuilt builder API via the std package as a replacement for separate, third-party build systems. It also offers the zig build command to execute build steps you store in the build.zig file.

You can scaffold a Zig build project with the following commands according to your preference:

  • zig init-exe: Initializes a Zig builder-based executable application
  • zig init-lib: Initializes a Zig builder-based library

The previous demo app was an executable-type app, so we can use the first command to learn about the Zig build system:

mkdir zig-exe-demo
cd zig-exe-demo

zig init-exe

The above command creates a new executable program with a demo source in src/main.zig. It adds several build steps to the build.zig file and we can execute them using the zig build command instead of zig run or zig build-exe.

For example, you can run your program by executing the run step as follows:

zig build run

You can create binaries using the install step:

zig build install

./zig-out/bin/zig-exe   # Runs the demo program binary

You can indeed add more build steps or intermediate processing by modifying the build.zig file, as you’d typically do in other build systems like CMake. This undoubtedly simplifies the Zig development process since you can run all your build automation steps via a single command, zig build.

Now you know how to write a basic Zig program, compile it using the Zig compiler, and use Zig’s inbuilt build system to simplify the development process. Let’s start learning the language syntax and features!

Zig primitive types

Like C, Zig supports various forms of integers, floating point numbers, and pointers. Let’s learn some primitive types that you should know.

The following code snippet defines some integers and floating-point numbers:

const std = @import("std");

pub fn main() void {
    var x: i8 = -100;     // Signed 8-bit integer
    var y: u8 = 120;      // Unsigned 8-bit integer
    var z: f32 = 100.324; // 32-bit floating point

    std.debug.print("x={}\n", .{x});        // x=-100
    std.debug.print("y={}\n", .{y});        // y=120
    std.debug.print("z={d:3.2}\n", .{z});   // z=100.32
}

Since the above identifier values never get changed, we can use the const keyword instead of var:

const x: i8 = -100;
// ...
// ...

As a modern language, Boolean types are also supported:

var initialized: bool = true;

You can store characters in unsigned bytes (8-bit integers) as follows:

const std = @import("std");

pub fn main() void {
    const l1: u8 = 'Z';
    const l2: u8 = 'i';
    const l3: u8 = 'g';

    std.debug.print("{c}-{c}-{c}\n", .{l1, l2, l3}); // Z-i-g
}

You can also define variables without writing their data types, as follows. Then, Zig will use comptime types to store them by guaranteeing the compile-time evaluation:

Zig also supports native C types (i.e., c_char, c_int, etc.). See all supported types in this table in the official documentation. There is no inbuilt string type in Zig, so instead, we have to use byte arrays. We’ll discuss arrays in a separate section of this tutorial.

Enums

Zig offers a simple syntax to define and access enumerations. Look at the following sample source:

const std = @import("std");

pub fn main() void {
    const LogType = enum {
        info,
        err,
        warn
    };

    const ltInfo = LogType.info;
    const ltErr = LogType.err;

    std.debug.print("{}\n", .{ltInfo}); // main.main.LogType.info
    std.debug.print("{}\n", .{ltErr});  // main.main.LogType.err
}

Zig lets you override the ordinal values of enums as follows:

const LogType = enum(u32) {
    info = 200,
    err = 500,
    warn = 600
};

Arrays and slices

Zig recommends you use arrays for compile-time-known values and slices for runtime-known values. For example, we can store English vowels in a constant character array as follows:

const std = @import("std");

pub fn main() void {
    const vowels = [5]u8{'a', 'e', 'i', 'o', 'u'};

    std.debug.print("{s}\n", .{vowels}); // aeiou
    std.debug.print("{d}\n", .{vowels.len}); // 5
}

Here, we can omit the size since it’s known at compile time:

const vowels = [_]u8{'a', 'e', 'i', 'o', 'u'}; // notice the "_"

You don’t need to use this approach to define strings because Zig lets you define strings in C-style, as follows:

const std = @import("std");

pub fn main() void {
    const msg = "Ziglang";

    std.debug.print("{s}\n", .{msg}); // Zig
    std.debug.print("{}\n", .{@TypeOf(msg)}); // *const [7:0]u8
}

Once you store a hardcoded string in an identifier, Zig automatically uses a null-terminated array reference (pointer to an array) *const [7:0]u8 to store elements. Here, we used the @TypeOf() built-in function to get the type of the variable. You can browse all supported built-in functions in the official documentation.

Arrays can be repeated or concatenated with the ** and ++ operators, respectively:

const std = @import("std");

pub fn main() void {
    const msg1 = "Zig";
    const msg2 = "lang";

    std.debug.print("{s}\n", .{msg1 ** 2}); // ZigZig
    std.debug.print("{s}\n", .{msg1 ++ msg2}); // Ziglang
}

Zig slices are almost like arrays, but they are used to store values that are not known at compile-time (but are known at runtime). Look at the following example that makes a slice from an array:

const std = @import("std");

pub fn main() void {
    const nums = [_]u8{2, 5, 6, 4};
    var x: usize = 3;
    const slice = nums[1..x];

    std.debug.print("{any}\n", .{slice});        // { 5, 6 }
    std.debug.print("{}\n", .{@TypeOf(slice)});  // []const u8
}

Here, the slice identifier became a slice because x was a runtime-known variable. If you use const for x, slice will become a pointer to an array (*const [2]u8) because x is known at compile-time. We’ll discuss pointers in an upcoming section.

Structs and unions

Structs are useful data structures for storing multiple values, and can even be used to implement object-oriented programming (OOP) concepts.

You can create structs and access their inner fields using the following syntax:

const std = @import("std");

pub fn main() void {
    const PrintConfig = struct {
        id: *const [4:0] u8,
        width: u8,
        height: u8,
        zoom: f32
    };

    const pc = PrintConfig {
        .id = "BAX1",
        .width = 200,
        .height = 100,
        .zoom = 0.234
    };

    std.debug.print("ID: {s}\n", .{pc.id});  // ID: BAX1
    std.debug.print("Size: {d}x{d} (zoom: {d:.2})\n",
        .{pc.width, pc.height, pc.zoom});  // Size: 200x100 (zoom: 0.23)
}

Structs can also have methods in Zig, so I will show an example when we discuss functions.

Zig unions are like structs, but they can have only one active field at a time. Look at the following example:

const std = @import("std");

pub fn main() void {
    const ActionResult = union {
        code_int: u8,
        code_float: f32
    };

    const ar1 = ActionResult { .code_int = 200 };
    const ar2 = ActionResult { .code_float = 200.13 };

    std.debug.print("code1 = {d}\n", .{ar1.code_int});  // code1 = 200
    std.debug.print("code2 = {d:.2}\n", .{ar2.code_float});  // code2 = 200.13
    // std.debug.print("code2 = {d:.2}\n", .{ar2.code_int});  // error!
}

Using control structures

Every programming language typically offers control structures to handle the logical flow of programs. Zig supports all general control structures, like if, switch, for, etc.

Look at the following example code snippet of an if...else statement:

const std = @import("std");

pub fn main() void {
    var score: u8 = 100;

    if(score >= 90) {
        std.debug.print("Congrats!\n", .{});
        std.debug.print("{s}\n", .{"*" ** 10});
    }
    else if(score >= 50) {
        std.debug.print("Congrats!\n", .{});
    }
    else {
        std.debug.print("Try again...\n", .{});
    }
}

This is an example of a switch statement:

const std = @import("std");

pub fn main() void {
    var score: u8 = 88;

    switch(score) {
        90...100 => {
            std.debug.print("Congrats!\n", .{});
            std.debug.print("{s}\n", .{"*" ** 10});
        },
        50...89 => {
            std.debug.print("Congrats!\n", .{});
        },
        else => {
            std.debug.print("Try again...\n", .{});
        }
    }
}

Here’s the example of a while statement:

const std = @import("std");

pub fn main() void {
    var x: u8 = 0;
    while(x < 11) {
        std.debug.print("{}\n", .{x});
        x += 1;
    }
}

And here’s a for statement example:

const std = @import("std");

pub fn main() void {
    const A = [_]u8 {2, 4, 6, 8};

    for (A) |n| {
        std.debug.print("{d}\n", .{n});
    }
}

Functions

Functions help us create reusable code segments by allowing us to name each segment with a callable identifier. We’ve already used a public function, main, to bootstrap our application earlier. Let’s create more and learn functions further.
Here is a simple function that returns the summation of two integers:

const std = @import("std");

fn add(a: i8, b: i8) i8 {
    return a + b;
}

pub fn main() void {
    const a: i8 = 10;
    const b: i8 = -2;
    const c = add(a, b);

    std.debug.print("{d} + {d} = {d}\n", .{a, b, c}); // 10 + -2 = 8
}

Recursion is also a programming feature that Zig offers, as do many other general-purpose languages:

const std = @import("std");

fn fibonacci(n: u32) u32 {
    if(n == 0 or n == 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

pub fn main() void {
    std.debug.print("{d}\n", .{fibonacci(2)});   // 1
    std.debug.print("{d}\n", .{fibonacci(12)});  // 144
}

As Go does, Zig lets you create methods within structs and use them as OOP methods, as shown in the following example:

const std = @import("std");

const Rectangle = struct {
    width: u32,
    height: u32,
    fn calcArea(self: *Rectangle) u32 {
        return self.width * self.height;
    }
};

pub fn main() void {
    var rect = Rectangle { .width = 200, .height = 25 };
    var area = rect.calcArea();
    std.debug.print("{d}\n", .{area});   // 5000
}

Pointers

Zig supports C-like pointers as a hardware-friendly, somewhat low-level language. Look at the following basic integer pointer:

const std = @import("std");

pub fn main() void {
    var x: u8 = 10;
    var ptr_x = &x;
    ptr_x.* = 12;

    std.debug.print("{d}\n", .{x});   // 12
    std.debug.print("{d}\n", .{ptr_x});   // [email protected]_address
    std.debug.print("{}\n", .{@TypeOf(ptr_x)});   // *u8
}

Here, C/C++ developers need to notice that we used the ptr.* syntax to de-reference a pointer, not *ptr as in C/C++. Pointers to array elements and pointers to the whole array also work as expected, as shown in the following code snippet:

const std = @import("std");

pub fn main() void {
    var A = [_]u8 {2, 5, 6, 1, 1};
    var ptr_x = &A[1];
    ptr_x.* = 12;

    std.debug.print("{d}\n", .{A[1]});            // 12
    std.debug.print("{d}\n", .{ptr_x});           // [email protected]_address
    std.debug.print("{}\n", .{@TypeOf(ptr_x)});   // *u8

    var ptr_y = &A;
    ptr_y[2] = 11;
    std.debug.print("{any}\n", .{A});             // { 2, 12, 11, 1, 1 }
    std.debug.print("{}\n", .{@TypeOf(ptr_y)});   // *[5]u8
}

Advanced language features

Here is a summary of some of Zig’s advanced language features that you should know:

  • Manual memory management via allocators and the defer keyword
  • Supports generics with a simple syntax
  • Offers productive keywords (async, suspend, and resume) for modern asynchronous programming
  • Provides automatic type coercion and manual type coercion via the inbuilt @as
  • Zig’s C-interop lets you call C APIs. See the following run in with the -lc flag to link against libc:
    const std = @import("std");
    const c = @cImport({
        @cInclude("stdio.h");
    });
    
    pub fn main() void {
        const char_count = c.printf("Hello %s\n", "C..."); // Hello C...
    
        std.debug.print("{}\n", .{@TypeOf(char_count)}); // c_int
        std.debug.print("{}\n", .{char_count}); // 11
    }

Keep an eye on the official Zig news page for the latest feature additions and advanced concept explanations.

Standard library APIs in Zig

We’ve discussed the Zig language syntax and features with the previous examples, but those concepts are not enough to develop general-purpose programs — we often need to use complex data structures, mathematical formulas, and operating-system-level APIs. Zig offers a fully-featured, but minimal standard library via the std namespace.

We’ll write a simple CLI program to learn several Zig standard library features. Add the following code to a new Zig source file (you can also fork the Gist here) and run it:

This demo CLI supports three integer actions:

  • 0: Instantiates the program
  • 1: Shows program help
  • 2: Prints the current Node.js version via the Zig child process API

Look at the following preview:

Our simple CLI built with Zig standard library APIs

Here, we used some error-handling basics and the standard library’s io namespace and ChildProcess struct. See all available namespaces and structs from the official standard library reference.

Open-source Zig ecosystem

Zig is a new language, so the open-source package availability and the developer resources are still growing. Check the following popular open-source Zig libraries:

See more stuff developed with/for Zig from the awesome-zig repository.

Zig vs. C vs. Rust

The following table summarizes several important comparison factors for Zig, C, and Rust:

Comparison factor Zig C Rust
Language influence Rust, C, and Python B Functional languages, C++
Initial release 2017 (0.1.0) 1972 2012 (0.1.0)
Language syntax complexity Minimal Minimal Complex
Primary paradigms Procedural Procedural Functional and procedural
Memory management Manual (via allocators) Manual (via malloc, etc) Manual (but improved to avoid security issues)
Performance and binary size Generates super-lightweight, faster binaries that have no dedicated runtime (can optionally link against libc) Generates super-lightweight, faster binaries that have a minimal runtime known as C runtime Generates faster binaries that have no dedicated runtime, and binary-size-related issues can be solved with these tips
Third-party library ecosystem Still growing, as a new language Mature library ecosystem, but has no standardized library registry and integration method (depends on build tools) Has a mature library ecosystem in the official package registry, called Crates
Developer resources Good, but only a few sources are available expect the official documentation Good Good
Standard library features Good Has low-level APIs only — need third-party libraries or have to write platform-specific code Good
Toolchain Fully-featured Compiler, linker, and assembler only — needs separate tools to run tests, advance building, use packages, etc. Fully-featured
C-interop Direct imports without FFI (Foreign Function Interface) N/A via Rust FFI

Conclusion

In this tutorial, we learned the concepts, goals, and design techniques behind the development of the Zig programming language. We learned the Zig language by testing generic, general-purpose programming facts that we can use to build modern computer programs.

Zig is still a new language, and more features are still being regularly implemented and tested by ZSF. Learning Zig is a great decision since it has a promising future as a better C language.

Get set up with LogRocket's modern error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID
  2. Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not server-side
  3. $ npm i --save logrocket 

    // Code:

    import LogRocket from 'logrocket';
    LogRocket.init('app/id');
    Add to your HTML:

    <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
    <script>window.LogRocket && window.LogRocket.init('app/id');</script>
  4. (Optional) Install plugins for deeper integrations with your stack:
    • Redux middleware
    • NgRx middleware
    • Vuex plugin
Get started now
Shalitha Suranga Programmer | Author of Neutralino.js | Technical Writer

Leave a Reply