Variables and Data Types
In this lesson, we'll explore how Zig handles variables and its type system. You'll learn about variable declaration, mutability, type inference, and the various data types available in Zig.
What You'll Learn
By the end of this lesson you will have built a small conversion toolkit: three functions that narrow an integer into a byte, divide two integers into a real average without losing the fraction, and round a float back to the nearest whole number — with a check battery that refuses to pass until each one picks the right builtin.
This lesson carries no capstone milestone of its own, but it is what the later ones are made of. Every number the record-analyzer eventually reports comes out of a file as bytes, gets counted as an integer, and gets printed as an average — and each of those steps is a conversion that either preserves the value or silently discards part of it.
You arrive here able to run a Zig program and read its output (Introduction to Zig). What is new is that values now have types with fixed widths, and Zig makes you say out loud whenever a value moves from one width to another. Everything below is pinned to Zig 0.16.0, the version the Run button uses — which matters in this lesson more than most, because 0.16 changed which builtin you reach for when converting a float to an integer.
Behaviour First: Two Declarations, One Difference
In Zig, you declare variables using const or var. The choice between them determines whether the
variable is immutable or mutable. Click Run and read the three lines it prints before reading on —
one name changes value, the other could not have.
const std = @import("std");
pub fn main() !void {
// Immutable variable (recommended when possible)
const x: i32 = 5;
std.debug.print("The value of x is: {}\n", .{x});
// This would cause a compile error:
// x = 6; // error: cannot assign to constant
// Mutable variable
var y: i32 = 5;
std.debug.print("The value of y is: {}\n", .{y});
y = 6; // This works!
std.debug.print("The value of y is now: {}\n", .{y});
}
Best Practice: Use
constby default and only usevarwhen you need to modify a value. This makes your code more predictable and easier to reason about.
Type Inference
Zig can often infer types from the value you assign, but you can also be explicit:
const std = @import("std");
pub fn main() !void {
// Type inference - Zig figures out the type
const x = 42; // stays comptime_int — no concrete type is forced here
const y = 3.14; // stays comptime_float for the same reason
const name = "Zig"; // *const [3:0]u8 — coerces to []const u8 where one is wanted
// Explicit type annotation
const z: u8 = 42; // Explicitly an 8-bit unsigned integer
const pi: f32 = 3.14; // Explicitly a 32-bit float
std.debug.print("x={}, y={d:.2}, name={s}, z={}, pi={d:.2}\n", .{x, y, name, z, pi});
}
An unannotated integer literal is not yet a concrete type — it is a comptime_int, and it only becomes a real machine integer when it is assigned somewhere that names a type. That has a consequence worth working out before you meet it in an error message.
Predict
Both consts start from the same literal 300. One of these three lines is a compile error. Which one, and why?
const wide: u16 = 300; // line A
const narrow: u8 = 300; // line B
const inferred = 300; // line CLine B is the error: 300 does not fit in a u8, whose range is 0 to 255, and because the coercion happens at compile time the compiler rejects the file instead of quietly wrapping the value. Lines A and C are both fine — 300 sits well inside u16, and an unannotated literal simply stays a comptime_int until something demands a concrete type.
Integer Types
Zig names an integer type by its signedness and its exact bit width — i8, u32, i64 — rather
than by a vague size class like int or long. This is fully worked; read the why-notes, because
the next two examples get progressively less help.
const std = @import("std");
pub fn main() !void {
// The width is IN THE NAME: i8 is exactly 8 bits, signed. Nothing about it
// varies by platform or compiler, which is why -128 is its exact floor.
const small: i8 = -128; // Range: -128 to 127
// Underscores are ignored by the compiler; they exist only so a human can
// see the magnitude at a glance.
const medium: i32 = -2_147_483_648;
const large: i64 = 9_223_372_036_854_775_807;
// The u prefix removes the sign bit, which is what buys the extra range at
// the top: a u8 reaches 255 where an i8 stops at 127.
const byte: u8 = 255; // Range: 0 to 255
const word: u16 = 65535;
const dword: u32 = 4_294_967_295;
// usize is the one width that DOES vary by platform — it is whatever an
// address is on the target. Use it for lengths and indices, and nothing else.
const ptr_sized: usize = 1024;
const signed_ptr: isize = -1024;
std.debug.print("Signed: {}, {}, {}\n", .{small, medium, large});
std.debug.print("Unsigned: {}, {}, {}\n", .{byte, word, dword});
std.debug.print("Pointer-sized: {}, {}\n", .{ptr_sized, signed_ptr});
}
Notice what the explicit widths buy you. A value's range is a fact you can read off the type without looking anything up, and the compiler can check a literal against it before the program exists — which is exactly what the prediction above tested.
Floating-Point Types
Floats follow the same naming rule — the number is the bit width — but they trade range for precision differently than integers do. Fewer notes this time: one is left for you.
const std = @import("std");
pub fn main() !void {
const half: f16 = 1.5;
const single: f32 = 3.14159;
const double: f64 = 3.141592653589793;
const quad: f128 = 3.141592653589793238462643383279502884197;
// The precision specifier after the colon says how many fraction digits to
// print — chosen here to match roughly what each width can actually hold.
std.debug.print("f16: {d:.2}\n", .{half});
std.debug.print("f32: {d:.5}\n", .{single});
std.debug.print("f64: {d:.15}\n", .{double});
std.debug.print("f128: {d:.30}\n", .{quad});
}
Your note to write: run it, then say why f64 is the one to reach for by default. The answer is
in the printed digits — compare what single shows at five places against what double shows at
fifteen, and ask which one you would trust to hold a money amount or a sensor average. Wider is not
free: an f64 occupies 8 bytes where an f32 occupies 4, and an f128 occupies 16 — you can check
each with @sizeOf. Pick the narrowest width that holds the precision you actually need.
Boolean Type
No rationale notes on this one — only labels, and that is the point. You have had the reasoning twice; read this one the way you will have to read code in the wild, and answer the questions below it from the source alone.
const std = @import("std");
pub fn main() !void {
const is_learning: bool = true;
const is_expert: bool = false;
std.debug.print("Learning Zig: {}\n", .{is_learning});
std.debug.print("Expert yet: {}\n", .{is_expert});
// Boolean operations
const both = is_learning and is_expert;
const either = is_learning or is_expert;
const not_expert = !is_expert;
std.debug.print("Both: {}, Either: {}, Not expert: {}\n", .{both, either, not_expert});
}
Read it and answer before running: bool has exactly two values, so what does {} print for
one — a number, or a word? And and / or are spelled as words rather than as && / ||. Given
everything this lesson has said about explicitness, why might a language that names bit widths
exactly also refuse to let & and and look alike? Run it and check the first answer; the second
one you can reason out.
Type Conversion
Zig requires explicit type conversions for safety. There are no implicit conversions:
const std = @import("std");
pub fn main() !void {
// Explicit type conversion using @as
const x: i32 = 42;
const y: f64 = @as(f64, @floatFromInt(x));
std.debug.print("Integer: {}\n", .{x});
std.debug.print("Converted to float: {d:.2}\n", .{y});
// Converting between integer types
const big: i32 = 1000;
const small: i16 = @intCast(big); // Use @intCast for integer conversions
std.debug.print("i32 to i16: {}\n", .{small});
// Converting float to integer — the rounding builtins do the conversion
const float_val: f64 = 42.9;
const truncated: i32 = @trunc(float_val); // toward zero: 42
const rounded: i32 = @round(float_val); // nearest: 43
std.debug.print("Float {d:.1} truncated: {}, rounded: {}\n", .{float_val, truncated, rounded});
// Try changing these values and conversions!
}
There is one conversion Zig will not stop you from getting wrong, because no conversion is involved: dividing two integers. The next program looks like it computes an average, and it runs without a single warning.
Debug
This is supposed to average 250 points across 4 students and print 'Average score: 62.50', then the rounded value 63. It compiles, it runs, and it prints the wrong numbers. Commit a hypothesis about where the precision goes, then fix it.
const std = @import("std");
pub fn main() !void {
const total_score: i32 = 250;
const student_count: i32 = 4;
const average: f64 = total_score / student_count;
std.debug.print("Average score: {d:.2}\n", .{average});
const rounded: i32 = @round(average);
std.debug.print("Rounded average: {}\n", .{rounded});
}Expected output:
Average score: 62.50 Rounded average: 63
The division runs in i32 before the f64 annotation is consulted: 250 / 4 is integer division, so the remainder is discarded and the result 62 then widens into the float. Converting each operand first — @as(f64, @floatFromInt(total_score)) / @as(f64, @floatFromInt(student_count)) — makes the division itself floating-point and gives 62.5, so @round produces 63. Operand types decide which arithmetic runs; the destination type only decides where the answer is stored.
Now assemble a conversion yourself. The lines below narrow one value and widen another, and only one arrangement gives both the right answer.
Arrange the code
These five lines take a raw i32 reading, narrow it into a u8 level, derive double that level in a wider type, report both, and then reset level for the next reading. They are shuffled. Put them in the order that prints level=200 and doubled=400 — then answer what the order is really testing: two of the lines silently capture a value at the instant they run rather than tracking it, so what does each one hold if it runs at the wrong moment?
level = 7;const raw: i32 = 200;var level: u8 = @intCast(raw);const doubled: u16 = @as(u16, level) * 2;std.debug.print("level={} doubled={}\n", .{ level, doubled });
Going from float to integer, you pick the builtin that names the rounding you want: @trunc chops toward zero, @floor and @ceil go down and up, and @round goes to nearest. All four infer the integer result type from the destination, so const n: i32 = @trunc(x); both rounds and converts in one step. (Older Zig code uses @intFromFloat for this. It still works, and you will meet it in nearly every existing codebase — but as of 0.16 it is deprecated, because @trunc already does the job. The 0.16.0 release notes say so in as many words, under "@floor, @ceil, @round, @trunc Conversion to Integers": @intFromFloat "is now redundant with @trunc and is therefore deprecated" (0.16.0 release notes). The compiler does not warn about it, so the release notes are the only place that tells you. Note the asymmetry: going the other way, integer to float, @floatFromInt is not deprecated and remains the idiomatic choice.)
Undefined and Null
Zig has special values for uninitialized and optional data:
const std = @import("std");
pub fn main() !void {
// Undefined - uninitialized value (use with caution)
var uninitialized: i32 = undefined;
uninitialized = 42; // Must initialize before use
std.debug.print("Initialized value: {}\n", .{uninitialized});
// Optional types - can be null
var maybe_number: ?i32 = null;
std.debug.print("Optional is null: {}\n", .{maybe_number == null});
maybe_number = 42;
std.debug.print("Optional has value: {?}\n", .{maybe_number});
// Unwrap optional with if
if (maybe_number) |value| {
std.debug.print("The value is: {}\n", .{value});
}
}
You have now met three of Zig's explicitness rules in one lesson. Close the code and see whether you can connect them back to the promise the first lesson made about the language.
Recall
Without scrolling up: Introduction to Zig summed the language up as 'no hidden control flow, no hidden allocations — what you see is what you get.' Which of these behaviours from this lesson is that same principle applied to types?
That principle is the answer: a lossy conversion needs a named builtin at the point it happens, so it is visible in the source rather than inserted for you. Widening coercions that cannot lose information stay silent because there is nothing hidden about them.
Recognising a rule in the place you learned it is easy. Here it is somewhere else.
Transfer
This lesson showed that a literal too large for its annotated type is rejected while the program is being built: const narrow: u8 = 300; does not compile. Now the same width limit meets a value that is not a literal — it comes from somewhere the compiler cannot see, and is narrowed with @intCast. What is the same, and what changed?
const raw: i32 = readSensor(); // value not known at compile time
const level: u8 = @intCast(raw); // raw might be 300Practice Exercises
Try these exercises in the playground above:
- Create variables of different integer types and perform arithmetic operations
- Declare a mutable variable, change its value multiple times, and print each change
- Create an optional value, set it to
null, then to a value, and print both states - Convert between different numeric types using the conversion functions
- Create boolean variables and practice logical operations (
and,or,not)
Try It Yourself
Reading about conversions is not the same as reaching for the right one. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one narrows an integer, one divides in floating point, one rounds back to an integer. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper that prints the failure and returns an error. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything — no hidden control flow, exactly as Zig prefers it.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide which conversion builtin that function needs, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const std = @import("std");
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
// TODO 1: narrow an i32 into a u8. The caller guarantees the value is in
// range, so no bounds handling is needed — just the conversion builtin.
// toByte(200) -> 200, toByte(0) -> 0
fn toByte(value: i32) u8 {
_ = value;
return 0;
}
// TODO 2: return the average of total over count as a real f64 — the
// fractional part must survive. Convert BEFORE dividing.
// averageOf(250, 4) -> 62.5, averageOf(7, 2) -> 3.5
fn averageOf(total: i32, count: i32) f64 {
_ = total;
_ = count;
return 0.0;
}
// TODO 3: convert an f64 to the NEAREST whole i32 (not toward zero).
// nearestWhole(62.5) -> 63, nearestWhole(3.4) -> 3, nearestWhole(-2.6) -> -3
fn nearestWhole(value: f64) i32 {
_ = value;
return 0;
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
try check(toByte(200) == 200, "toByte(200) should be 200");
try check(toByte(0) == 0, "toByte(0) should be 0");
try check(averageOf(250, 4) == 62.5, "averageOf(250, 4) should be exactly 62.5, not 62");
try check(averageOf(7, 2) == 3.5, "averageOf(7, 2) should be exactly 3.5 — the remainder must survive");
try check(nearestWhole(62.5) == 63, "nearestWhole(62.5) should round UP to 63");
try check(nearestWhole(3.4) == 3, "nearestWhole(3.4) should be 3");
try check(nearestWhole(-2.6) == -3, "nearestWhole(-2.6) should be -3 — nearest, not toward zero");
std.debug.print("All checks passed.\n", .{});
std.debug.print("Byte: {}\n", .{toByte(200)});
std.debug.print("Average of 250 over 4: {d:.2}\n", .{averageOf(250, 4)});
std.debug.print("Nearest whole: {}\n", .{nearestWhole(62.5)});
}Expected output:
All checks passed. Byte: 200 Average of 250 over 4: 62.50 Nearest whole: 63
Once it passes, try two variations and predict each before running:
- Convert after dividing instead of before. In
averageOf, change the body toreturn @as(f64, @floatFromInt(@divTrunc(total, count)));— a single conversion applied to the integer result. Predict which check fails before running. TheaverageOf(250, 4)check fails: integer division has already thrown the remainder away, so you widen62and get62.0, not62.5. Where the conversion sits relative to the division is the whole difference. - Swap the rounding builtin. In
nearestWhole, change@roundto@trunc. Predict which of the three checks survive before running.nearestWhole(3.4)still gives3and passes, butnearestWhole(62.5)gives62instead of63, so the first of the three fails —@truncchops toward zero and never rounds up. Same conversion, different rounding decision.
Key Takeaways
- Use
constfor immutable variables andvarfor mutable ones - Zig has explicit integer types with specific bit sizes (i8, u32, etc.)
- Type conversions must be explicit using built-in functions
- Optional types use
?to represent values that might be null - The
undefinedvalue represents uninitialized data
Next Steps
Now that you understand variables and types, let's explore how to create reusable code with functions in the next lesson.