Basic Syntax: Ramp Up on Zig with Your Go/Rust Experience

This article is based on Zig 0.16.

In Part 1 we discussed why Zig is worth learning and how to run your first Hello World. Now it’s time to dive into the syntax—variables, types, control flow, functions, and generics. The single goal is to make you able to read and write Zig code.

If you have Go or Rust experience, none of these concepts will feel entirely new. I’ll draw comparisons at key points to help you map your existing knowledge.

Variables and Constants

Zig’s variable declarations are refreshingly simple. The core principle is prefer const; only use var when mutation is needed:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const std = @import("std");

pub fn main() void {
    // Constants (immutable) — prefer these
    const pi: f64 = 3.14159;
    const name = "Zig"; // type inference

    // Variables (mutable)
    var count: i32 = 0;
    count += 1;

    // Undefined value (similar to Rust's MaybeUninit)
    var x: i32 = undefined;
    x = 42;
}

Go/Rust comparison:

GoRustZig
Immutable bindingconst x = 5 (package-level)let x = 5const x = 5
Mutable bindingx := 5 or var x = 5let mut x = 5var x = 5
Type inferencex := 5 inferslet x = 5 infersconst x = 5 infers
Type annotationvar x int = 5let x: i32 = 5const x: i32 = 5

Zig doesn’t have := or letconst and var are the only keywords. Type inference is the default, and annotations are optional.

One subtle point: Zig’s const behaves like Rust’s let — it means the binding is immutable, not that the value is a compile-time constant. Compile-time constants use comptime, which we’ll see later.

undefined works similarly to Rust’s MaybeUninit — it tells the compiler to skip initialization, and you promise to assign a value before use. It’s commonly used for array buffers or test scaffolding.

Basic Data Types

Zig’s type system retains traditional categories while introducing some unique capabilities:

CategoryZig TypesNotes
Signed integeri8, i16, i32, i64, i128Arbitrary widths like i7, i24
Unsigned integeru8, u16, u32, u64, u128usize = pointer-sized unsigned (C’s uintptr_t)
Floating pointf16, f32, f64, f80, f128IEEE 754
Booleanbooltrue / false
String[]const u8Byte slice, UTF-8 encoded
Pointer*T, *const TMutable / immutable pointer

The most notable difference for Go/Rust developers is arbitrary-width integers. Beyond the standard 8/16/32/64/128-bit widths, Zig allows any bit width — for instance i7 (signed 7-bit) or u24 (unsigned 24-bit). This is especially useful for network protocols and hardware registers — you no longer need manual masking and bit shifting; the compiler generates the most appropriate instructions.

In Go, if you need a 10-bit protocol field, you’d declare it as uint16 and manually mask with & 0x03FF. In Zig, you declare u10 and let the compiler handle all boundary checks.

usize is a platform-dependent unsigned integer (C’s uintptr_t), commonly used for slice indices and array lengths.

The string type []const u8 looks more verbose than Go’s string or Rust’s &str, but it’s refreshingly literal — it’s just a “read-only slice of u8”. Zig has no implicit string type; the literal "hello" is actually *const [5:0]u8 (a zero-terminated pointer), but it coerces to []const u8 automatically when assigned.

Control Flow

if Expressions

Zig’s if is an expression, just like Rust’s — it can return a value:

zig
1
2
3
4
fn max(a: i32, b: i32) i32 {
    // if is an expression, can return a value
    return if (a > b) a else b;
}

Go’s if is a statement (no return value); Rust’s if is an expression; Zig follows Rust here.

Zig also has a distinctive feature — if can capture both the success value and the error from an error union type:

zig
1
2
3
4
5
6
7
8
fn doSomething() !void {
    const result = mayFail();
    if (result) |value| {
        // Success — value is the unwrapped value
    } else |err| {
        // Failure — err is the error
    }
}

Here !T is Zig’s error union type — the return value is either T or an error. This is equivalent to Rust’s Result<T, E> or Go’s (T, error).

Compared to Rust’s if let Ok(value) = result, Zig’s syntax is more compact — no pattern matching syntax, just pipe capture. We’ll cover error handling in full detail in Part 3; for now, just get the general idea.

switch Expressions

switch is also an expression and must be exhaustive — consistent with Rust’s match, but different from Go’s switch (which just executes the first matching case):

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const Color = enum { red, green, blue };

fn colorToString(c: Color) []const u8 {
    return switch (c) {
        .red => "Red",
        .green => "Green",
        .blue => "Blue",
    };
}

// Range matching
fn classify(n: i32) []const u8 {
    return switch (n) {
        0 => "Zero",
        1...9 => "Single digit",
        10...99 => "Two digits",
        else => "Other",
    };
}

Each branch uses => to connect a value with its expression, matching Rust’s match arrow syntax. Zig additionally supports ... range matching — the equivalent of Rust’s ..= patterns. The else branch acts as a wildcard, catching all unlisted values.

Loops

Zig has only two loop types — for and while — no equivalent of Rust’s loop:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pub fn main() void {
    // for loop (iterating over slices)
    const items = [_]i32{ 1, 2, 3, 4, 5 };
    for (items) |item| {
        std.debug.print("{}\n", .{item});
    }

    // for loop with index
    for (items, 0..) |item, i| {
        std.debug.print("[{}] {}\n", .{i, item});
    }

    // while loop
    var i: usize = 0;
    while (i < 10) : (i += 1) {
        std.debug.print("{}\n", .{i});
    }

    // while with continue expression
    var sum: i32 = 0;
    var j: i32 = 1;
    while (j <= 100) : (j += 1) {
        sum += j;
    }
}

Three-language comparison:

ScenarioGoRustZig
Iterate over collectionfor i, v := range itemsfor (i, v) in items.iter().enumerate()for (items, 0..) |item, i|
Index-based loopfor i := 0; i < n; i++for i in 0..nwhile :(i += 1)
Condition loopfor i < n { ... }while condition { ... }while (condition) { ... }

The for syntax is for (target, optional_range) |element, optional_index| { body }. Multiple collections can be separated by commas.

After the while condition, a colon connects a continue expressionwhile (condition) : (continue expression) — executed at the end of each loop iteration, before the next condition check. This fills the role of C’s for third clause. Go doesn’t have this construct; Rust’s while doesn’t either; you’d use a for i in 0..n range loop or manually increment at the end of the body.

Functions

Zig defines functions with fn, and the return type comes after the parameter list:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Basic function
fn add(a: i32, b: i32) i32 {
    return a + b;
}

// Multiple return values (via anonymous struct)
fn divide(a: f64, b: f64) struct { quotient: f64, remainder: f64 } {
    return .{
        .quotient = a / b,
        .remainder = @mod(a, b),
    };
}

// Generic function (using comptime type parameter)
fn genericAdd(comptime T: type, a: T, b: T) T {
    return a + b;
}

// Calling
const result = genericAdd(i32, 1, 2);
const fresult = genericAdd(f64, 1.5, 2.5);

Comparison:

FeatureGoRustZig
Function signaturefunc add(a, b int) intfn add(a: i32, b: i32) -> i32fn add(a: i32, b: i32) i32
Multiple returnsNative supportVia tuple/structVia anonymous struct
Generics[T any]fn foo\<T: Trait\>()fn foo(comptime T: type, ...)

Zig doesn’t have Go’s native multi-return, but anonymous structs achieve the same effect while providing field names that make the return value’s semantics clearer to callers.

comptime T: type is the core pattern for Zig generics — comptime tells the compiler the parameter is resolved at compile time, and type says this parameter is a type. The compiler instantiates the function with the concrete type at compile time, with zero runtime overhead.

anytype Generics

anytype is Zig’s more concise generic syntax. It allows a function parameter to accept any type, with the compiler inferring types automatically from the actual argument:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Generic function using anytype
fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return a + b;
}

// Get type info with @TypeOf
fn printType(value: anytype) void {
    std.debug.print("Type: {}\n", .{@TypeOf(value)});
}

pub fn main() void {
    // Automatic type inference
    const int_result = add(1, 2);      // i32
    const float_result = add(1.5, 2.5); // f64

    printType(42);     // Type: comptime_int
    printType("hello"); // Type: *const [5:0]u8
}

Comparison:

LanguageSyntaxConstraint Approach
Go (1.18+)func add[T constraints.Ordered](a, b T) TExplicit type constraints
Rustfn add\<T: Add\<Output = T\>\>(a: T, b: T) -> TTrait bounds
Zigfn add(a: anytype, b: @TypeOf(a)) @TypeOf(a)No explicit constraints, compiler infers

anytype is essentially compile-time duck typing — as long as the passed type supports the operations used in the function body (+ in this case), compilation succeeds. The syntax is much shorter than Go or Rust generics, but the trade-off is that error messages can be more opaque: if you call add on a type that doesn’t support +, the error originates from inside the function body rather than at the function signature.

@TypeOf is a Zig built-in function that retrieves the type of an expression at compile time. It returns the type itself, which can be assigned to a type variable for further compile-time operations.

Summary

We’ve now covered the core of Zig’s basic syntax — variables and constants, basic data types, control flow, functions, and anytype generics. You can already read most Zig code.

If you remember one sentence: Zig’s syntax sits between C’s simplicity and Rust’s expressiveness — const/var for declarations, fn for functions, if/switch as expressions, comptime and anytype for compile-time generics.

Next up, we’ll dive into Zig’s most distinctive feature: error handling — error sets, the !T error union type, try, and catch. This is the foundation of safe Zig programming and the area where Go/Rust developers will need the most adaptation.