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:
| |
Go/Rust comparison:
| Go | Rust | Zig | |
|---|---|---|---|
| Immutable binding | const x = 5 (package-level) | let x = 5 | const x = 5 |
| Mutable binding | x := 5 or var x = 5 | let mut x = 5 | var x = 5 |
| Type inference | x := 5 infers | let x = 5 infers | const x = 5 infers |
| Type annotation | var x int = 5 | let x: i32 = 5 | const x: i32 = 5 |
Zig doesn’t have := or let — const 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:
| Category | Zig Types | Notes |
|---|---|---|
| Signed integer | i8, i16, i32, i64, i128 | Arbitrary widths like i7, i24 |
| Unsigned integer | u8, u16, u32, u64, u128 | usize = pointer-sized unsigned (C’s uintptr_t) |
| Floating point | f16, f32, f64, f80, f128 | IEEE 754 |
| Boolean | bool | true / false |
| String | []const u8 | Byte slice, UTF-8 encoded |
| Pointer | *T, *const T | Mutable / 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:
| |
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:
| |
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):
| |
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:
| |
Three-language comparison:
| Scenario | Go | Rust | Zig |
|---|---|---|---|
| Iterate over collection | for i, v := range items | for (i, v) in items.iter().enumerate() | for (items, 0..) |item, i| |
| Index-based loop | for i := 0; i < n; i++ | for i in 0..n | while :(i += 1) |
| Condition loop | for 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 expression — while (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:
| |
Comparison:
| Feature | Go | Rust | Zig |
|---|---|---|---|
| Function signature | func add(a, b int) int | fn add(a: i32, b: i32) -> i32 | fn add(a: i32, b: i32) i32 |
| Multiple returns | Native support | Via tuple/struct | Via 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:
| |
Comparison:
| Language | Syntax | Constraint Approach |
|---|---|---|
| Go (1.18+) | func add[T constraints.Ordered](a, b T) T | Explicit type constraints |
| Rust | fn add\<T: Add\<Output = T\>\>(a: T, b: T) -> T | Trait bounds |
| Zig | fn 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.