Beginner

Variables and Types in Zig

Learn about variables, primitive types, integer bit widths, optionals, and explicit type conversions in Zig with practical Docker-ready examples

Zig is a systems programming language with a static, strong type system that prefers being explicit over being clever. Every binding is either const (immutable) or var (mutable), every numeric conversion between types must be spelled out with a built-in, and “maybe a value, maybe nothing” is encoded directly into the type with the ? optional marker rather than left to runtime convention.

This tutorial introduces Zig’s primitive types — including its unusual ability to declare integers of any bit width — along with type inference, mutability rules, and the explicit cast built-ins that distinguish Zig from C. By the end you’ll know how to declare bindings, pick appropriate numeric types, and convert between them safely.

Declaring Bindings: const vs var

Zig has two keywords for introducing a name:

  • const — the binding cannot be reassigned. Prefer this; the compiler will tell you to use const if a var is never mutated.
  • var — the binding is mutable. Requires a type annotation or an initializer the compiler can infer from.

Type annotations are written after a colon (name: Type = value). They are optional when the compiler can infer the type from the right-hand side.

Create a file named variables.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
26
27
const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    // const = immutable binding, var = mutable
    const language: []const u8 = "Zig";
    var year: i32 = 2016;
    year += 9; // var can be reassigned; const cannot

    // Type inference: no annotation needed when the type is obvious
    const is_systems_lang = true;
    const answer = 42;

    // Integers of any bit width — not just 8/16/32/64
    const byte_value: u8 = 255;          // unsigned 8-bit
    const big_int: i64 = 9_000_000_000;  // signed 64-bit, underscores for readability
    const three_bits: u3 = 7;            // unsigned 3-bit (values 0..7)

    try stdout.print("language          = {s}\n", .{language});
    try stdout.print("year              = {d}\n", .{year});
    try stdout.print("is_systems_lang   = {}\n", .{is_systems_lang});
    try stdout.print("answer            = {d}\n", .{answer});
    try stdout.print("byte_value (u8)   = {d}\n", .{byte_value});
    try stdout.print("big_int (i64)     = {d}\n", .{big_int});
    try stdout.print("three_bits (u3)   = {d}\n", .{three_bits});
}

A few things worth noting:

  • u3 is a real type. Zig lets you declare integers of arbitrary bit widths from u0/i0 up to u65535/i65535. The compiler enforces the range — assigning 8 to a u3 is a compile error.
  • usize and isize are pointer-sized integers (used for array lengths and indexes), analogous to size_t in C.
  • String literals like "Zig" have type []const u8 — a slice of constant bytes. Zig doesn’t have a separate String type at this level.
  • The underscore separator (9_000_000_000) is purely cosmetic and works in all numeric literals.

Type Conversions and Optionals

Zig requires every numeric conversion between types to be explicit via a built-in function (the ones prefixed with @). There is no implicit widening, no implicit narrowing, and no surprise truncation. This eliminates an entire category of C bugs.

Optionals are Zig’s answer to “this might be missing.” Prefixing a type with ? produces a new type that can hold either a value of that type or null.

Create a file named conversions.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
26
27
28
29
30
31
32
33
34
35
const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    // Float types: f16, f32, f64, f80, f128
    const pi: f64 = 3.14;

    // int -> float and float -> int require explicit built-ins
    const int_val: i32 = 42;
    const as_float: f64 = @floatFromInt(int_val);

    const float_val: f64 = 9.75;
    const as_int: i32 = @intFromFloat(float_val); // truncates toward zero

    // Integer-to-integer casts use @intCast (must fit at runtime in safe modes)
    const wide: i64 = 1000;
    const narrow: i16 = @intCast(wide);

    // Optionals: a value of type T or null
    var maybe_name: ?[]const u8 = null;
    try stdout.print("before assignment: {?s}\n", .{maybe_name});

    maybe_name = "Andrew";
    try stdout.print("after assignment:  {?s}\n", .{maybe_name});

    // Unwrap with `orelse` to provide a default
    const name = maybe_name orelse "Unknown";

    try stdout.print("pi               = {d:.2}\n", .{pi});
    try stdout.print("int -> float     = {d:.1}\n", .{as_float});
    try stdout.print("float -> int     = {d}\n", .{as_int});
    try stdout.print("i64 {d} -> i16 = {d}\n", .{ wide, narrow });
    try stdout.print("unwrapped name   = {s}\n", .{name});
}

Key cast built-ins to remember:

  • @intCast — convert between integer types of different widths.
  • @floatCast — convert between float types.
  • @floatFromInt / @intFromFloat — convert across the int/float boundary.
  • @as(T, value) — coerce a value to a specific type when inference isn’t enough.

In ReleaseSafe and Debug builds, narrowing casts that would lose data (e.g. assigning 70_000 to a u16) trap at runtime. In ReleaseFast they are undefined behavior — Zig’s safety is opt-in by build mode.

Running with Docker

1
2
3
4
5
6
7
8
# Pull the official image
docker pull kassany/alpine-ziglang:0.14.0

# Run the variables example
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run variables.zig

# Run the conversions example
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run conversions.zig

Expected Output

Running variables.zig:

language          = Zig
year              = 2025
is_systems_lang   = true
answer            = 42
byte_value (u8)   = 255
big_int (i64)     = 9000000000
three_bits (u3)   = 7

Running conversions.zig:

before assignment: null
after assignment:  Andrew
pi               = 3.14
int -> float     = 42.0
float -> int     = 9
i64 1000 -> i16 = 1000
unwrapped name   = Andrew

Key Concepts

  • const is the default — Zig nudges you toward immutable bindings. Use var only when you actually need to reassign, or the compiler will complain.
  • Arbitrary-width integers — types like u3, i7, or u128 are first-class. Pick the smallest type that fits the data; the compiler enforces the range.
  • No implicit numeric conversions — every cast is spelled out with a built-in (@intCast, @floatFromInt, @as, etc.). This is more typing but eliminates silent overflow and truncation bugs.
  • Optionals replace null pointers?T is a distinct type from T. You cannot accidentally use a null where a value is required; you must unwrap with orelse, if (opt) |v|, or .?.
  • Strings are byte slices[]const u8 is the idiomatic string type. There is no separate String class with hidden allocations.
  • Safety depends on build mode — Debug and ReleaseSafe trap on out-of-range casts and integer overflow; ReleaseFast and ReleaseSmall trade those checks for speed.
  • Underscores in literals — write 1_000_000 or 0xFF_FF to keep large numbers readable; the compiler ignores them.

Running Today

All examples can be run using Docker:

docker pull kassany/alpine-ziglang:0.14.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining