An optional in Zig is a type that can either contain a value or contain nothing. Tony Hoare, creator of the null reference (null), described it decades later as “the billion-dollar mistake”.
In many languages, references and pointers could be null without the type distinguishing it. If we forget a check, the failure appears during execution as a NullPointerException or an invalid access.
Zig forces us to reflect that possibility in the type and to handle it before using the value.
In Zig, a normal type can NEVER be null.
var numero: i32 = null; // ERROR: expected type 'i32', found 'null'
var ptr: *u8 = null; // ERROR: expected type '*u8', found 'null'If you want something to be able to “have no value”, you must explicitly declare it as Optional by prefixing the type with a question mark ?.
Declaring optionals (?T)
An optional ?T is, conceptually, a box that can either contain a value of type T or be empty (null).
// Can contain an integer or null
var posible_edad: ?i32 = null;
posible_edad = 25; // Now it has a value
posible_edad = null; // It's empty againThe brilliant thing is that ?i32 and i32 are different types. You cannot add them or mix them directly. The compiler forces you to check if there is a value before using it.
const a: ?i32 = 10;
const b: i32 = 5;
// ERROR: operator + not allowed for type '?i32'
// const suma = a + b; Zig is telling you: “Hey, a could be empty. Tell me what to do in that case before adding.”
Safe unwrapping
To use the value of an optional, we need to “unwrap” it. Zig offers us three main ways to do this.
Capturing the value with if
This is the most common structure in Zig. We use if (optional) |variable_name|.
const input: ?i32 = obtenerValorDelUsuario();
if (input) |valor| {
// Inside this block, 'valor' is of type i32 (not ?i32)
// It is safe to use it.
std.debug.print("The user entered: {d}\n", .{valor});
} else {
std.debug.print("The user entered nothing\n", .{});
}It’s elegant because it combines the check (!= null) and the value extraction in a single line.
Default values with orelse
Sometimes we just want to say: “Use this value, and if it’s null, use this other default instead”.
const config_usuario: ?u32 = null;
// If config_usuario is null, we use 8080
const puerto = config_usuario orelse 8080; orelse also allows control flow. We can do a return or break if the value is missing.
// If null, we exit the function immediately
const dato = obtenerDato() orelse return error.FalloGrave;Forced unwrapping (.?)
Sometimes, we (as programmers) know that a value cannot be null due to program logic, even though the type says it can.
In those cases, we can use .? to force the extraction.
const numero: ?i32 = 10;
const valor = numero.?; // We get the 10What happens if you are wrong?
Using .? when the value is null is illegal behavior. In Debug and ReleaseSafe, Zig detects it and throws a panic; ReleaseFast and ReleaseSmall don’t necessarily have to retain that check.
Optional pointers (?*T)
If we have an optional of an integer ?u8:
- We need 8 bits for the number.
- We need 1 extra bit (boolean) to know if it is null or not.
- Due to memory alignment, this usually takes up more space than expected.
For pointers that do not allow the zero address, Zig uses a more compact representation.
We know that memory address 0 is never valid. Zig takes advantage of this. In a ?*T, Zig uses address 0 to represent null.
This means that a ?*T occupies the same as a *T.
- Without an additional tag: the zero address itself represents
null. - Explicit check: the type forces you to unwrap the pointer before using it as
*T.
// In C, this would be a pointer that could be NULL (dangerous)
// In Zig, it's explicit and safe.
const Nodo = struct {
valor: i32,
siguiente: ?*Nodo, // Pointer to the next node or null
};Loops with optionals
Optionals work very well with while loops. We can use the capture syntax to iterate while a function returns a value and stop when it returns null.
var i: u32 = 0;
fn iterador() ?u32 {
if (i >= 3) return null;
i += 1;
return i;
}
pub fn main() void {
// Execute the loop while iterador() returns something other than null
while (iterador()) |num| {
std.debug.print("Number: {d}\n", .{num});
}
}