Optional unwrapping means taking the value out of an optional.
An optional value has this shape:
const maybe_number: ?i32 = 42;The type is ?i32, not i32.
That means you cannot use it directly as a normal integer:
const doubled = maybe_number * 2; // errorThe compiler rejects this because maybe_number might be null.
Before you can use the inner value, you must unwrap it.
Unwrapping with if
The most common way to unwrap an optional is with if.
const std = @import("std");
pub fn main() void {
const maybe_number: ?i32 = 42;
if (maybe_number) |number| {
std.debug.print("number: {}\n", .{number});
}
}This line is the key:
if (maybe_number) |number| {Read it as:
If maybe_number contains a value, call that value number and run this block.Inside the block, number is a plain i32.
const doubled = number * 2;Outside the block, maybe_number is still optional.
if (maybe_number) |number| {
// number is i32 here
}
// maybe_number is still ?i32 hereThe unwrapped name only exists inside the block.
Handling the null Case with else
Most real code needs to handle both cases: value and no value.
const std = @import("std");
pub fn main() void {
const maybe_name: ?[]const u8 = null;
if (maybe_name) |name| {
std.debug.print("hello, {s}\n", .{name});
} else {
std.debug.print("hello, guest\n", .{});
}
}If maybe_name contains a string, the first block runs.
If it is null, the else block runs.
This is the safest basic pattern:
if (optional_value) |value| {
// use value
} else {
// handle null
}Unwrapping with orelse
Use orelse when you want a default value.
const maybe_port: ?u16 = null;
const port = maybe_port orelse 8080;Now port is a normal u16.
If maybe_port has a value, that value is used.
If maybe_port is null, 8080 is used.
Another example:
const maybe_name: ?[]const u8 = null;
const name = maybe_name orelse "guest";This is often cleaner than writing a full if when the missing case has a simple default.
orelse Can Return
The right side of orelse does not need to be a plain value. It can also return from the current function.
fn printName(maybe_name: ?[]const u8) void {
const name = maybe_name orelse return;
std.debug.print("name: {s}\n", .{name});
}This means:
If maybe_name has a value, store it in name.
If maybe_name is null, return from the function.After this line, name is no longer optional.
const name = maybe_name orelse return;
// name is []const u8 hereThis pattern is useful when a function cannot do anything useful without the value.
Unwrapping with .?
Zig also has a shorter unwrap operator:
const value = maybe_number.?;This means:
Take the value out of the optional.
If it is null, panic.Example:
const std = @import("std");
pub fn main() void {
const maybe_number: ?i32 = 42;
const number = maybe_number.?;
std.debug.print("{}\n", .{number});
}This works because maybe_number contains 42.
But this will crash:
const maybe_number: ?i32 = null;
const number = maybe_number.?; // panicUse .? only when null would be a bug, not a normal possibility.
Good use:
const value = cache.get("known_key").?;This is reasonable only if the program has already guaranteed that "known_key" exists.
Poor use:
const user = findUser(id).?;If the user may be missing in normal use, this is not good. Handle the null case instead.
Capturing a Pointer with if
Sometimes you want to modify the value inside an optional variable.
For that, use pointer capture.
const std = @import("std");
pub fn main() void {
var maybe_count: ?i32 = 10;
if (maybe_count) |*count| {
count.* += 1;
}
std.debug.print("{}\n", .{maybe_count.?});
}This part matters:
if (maybe_count) |*count| {The *count means “capture a pointer to the inner value.”
Inside the block, count is a pointer. To read or write the value, use count.*.
count.* += 1;After the block, maybe_count contains 11.
Use pointer capture when you want to mutate the value inside the optional.
Optional Unwrapping in while
You can unwrap optionals in a while loop too.
This is useful when a function repeatedly returns either a value or null.
const std = @import("std");
fn nextNumber(index: *usize, numbers: []const i32) ?i32 {
if (index.* >= numbers.len) {
return null;
}
const value = numbers[index.*];
index.* += 1;
return value;
}
pub fn main() void {
const numbers = [_]i32{ 10, 20, 30 };
var index: usize = 0;
while (nextNumber(&index, numbers[0..])) |number| {
std.debug.print("{}\n", .{number});
}
}The loop runs as long as nextNumber returns a value.
When nextNumber returns null, the loop stops.
This pattern appears in iterators and parsers.
Optional Pointers
Optional pointers are common.
const maybe_ptr: ?*i32 = null;Before using the pointer, unwrap it:
if (maybe_ptr) |ptr| {
ptr.* = 123;
}Inside the block, ptr is a normal *i32.
This is safer than allowing every pointer to be null. In Zig, a plain pointer like *i32 is expected to be non-null. If null is possible, the type must say so.
*i32 // pointer to i32
?*i32 // maybe pointer to i32That small ? matters.
Choosing the Right Unwrapping Style
Use if when you need to handle both cases clearly.
if (maybe_value) |value| {
// value case
} else {
// null case
}Use orelse when you want a default.
const value = maybe_value orelse default_value;Use orelse return when the function should stop if the value is missing.
const value = maybe_value orelse return;Use .? only when null would mean a programming mistake.
const value = maybe_value.?;Do not use .? just to avoid writing proper handling code.
The Main Idea
Optional unwrapping is Zig’s way of making missing values explicit.
A value of type ?T cannot be silently treated as T.
You must decide what to do:
Use the value.
Use a default.
Return early.
Handle the missing case.
Or assert that missing is impossible.
That decision is part of the program. Zig makes you write it down.