Semantic analysis is the compiler stage that checks what a program means.
Parsing checks the shape of the code. Semantic analysis checks whether that shape is valid according to the rules of Zig.
For example, this code has a valid shape:
const x: u8 = 300;The parser can understand it. It sees a constant declaration named x, with type u8, initialized with the integer literal 300.
But the program is wrong.
A u8 can only hold values from 0 to 255. The number 300 is too large. The parser does not decide that. Semantic analysis does.
So the basic distinction is:
parsing = structure
semantic analysis = meaningNames Must Be Resolved
When you write a name, the compiler must find what that name refers to.
Example:
const answer = 42;
pub fn main() void {
const x = answer;
_ = x;
}Inside main, the name answer refers to the top-level constant.
Semantic analysis connects the use of answer to its declaration.
If the name does not exist, the compiler reports an error:
pub fn main() void {
const x = missing_name;
_ = x;
}The parser can parse this. The code has a valid shape.
But semantic analysis cannot resolve missing_name.
The compiler must know what every name means before it can produce correct code.
Types Must Match
Zig is statically typed.
That means types are checked before the program runs.
Example:
fn add(a: i32, b: i32) i32 {
return a + b;
}
pub fn main() void {
const result = add(10, 20);
_ = result;
}This is valid. The function expects two i32 values and returns an i32.
Now compare:
fn add(a: i32, b: i32) i32 {
return a + b;
}
pub fn main() void {
const result = add("hello", "world");
_ = result;
}The parser accepts the structure. It sees a function call with two arguments.
Semantic analysis rejects it because the arguments are string literals, not i32 values.
This is one of semantic analysis’s main jobs:
find the type of each expression
check that each type is allowed where it appearsType Inference Happens Here
Zig often lets the compiler infer a type.
Example:
const x = 123;You did not write the type of x. The compiler must infer it from the initializer.
Another example:
const name = "zig";The compiler infers a string-literal type.
Type inference does not mean Zig is dynamically typed. The type is still known at compile time. You simply did not write it explicitly.
Semantic analysis is where these decisions happen.
Integer Literals Are Special
Integer literals in Zig are not immediately fixed to one machine type.
Example:
const x: u8 = 42;The literal 42 can fit in u8, so this works.
But:
const x: u8 = 300;does not work.
The compiler checks whether the literal can fit into the destination type.
This is why integer literals often feel flexible but still safe. They can adapt to context, but they cannot silently overflow into a smaller type.
Function Calls Are Checked
When you call a function, semantic analysis checks several things.
Example:
fn repeat(value: u8, count: usize) void {
_ = value;
_ = count;
}
pub fn main() void {
repeat(7, 3);
}The compiler checks:
Does repeat exist?
Is repeat callable?
How many parameters does it have?
Do the argument types match?
Can each argument be coerced safely?
What is the return type?If you pass too few arguments:
repeat(7);the compiler rejects the call.
If you pass too many:
repeat(7, 3, 9);the compiler rejects that too.
The syntax of both calls is valid. The meaning is invalid.
Return Values Are Checked
Semantic analysis also checks function return values.
Example:
fn getNumber() i32 {
return 123;
}This is valid.
But:
fn getNumber() i32 {
return "hello";
}is invalid because the function promises to return i32, but it returns a string.
The compiler also checks that a function returns when it must.
Example:
fn getNumber(flag: bool) i32 {
if (flag) {
return 1;
}
}If flag is false, the function reaches the end without returning an i32. Semantic analysis rejects this.
Control Flow Is Checked
Semantic analysis understands control flow.
It checks whether branches, loops, and blocks are valid.
Example:
const value = if (true) 10 else 20;Both branches produce integer values, so this can be used as an expression.
But:
const value = if (true) 10 else "no";The two branches do not have a compatible result type. Semantic analysis must reject or require a clear type resolution depending on context.
Control flow also matters for unreachable code.
Example:
fn crash() noreturn {
@panic("stop");
}A function returning noreturn never returns normally. Semantic analysis uses this information when checking code paths.
Errors Are Part of Types
In Zig, errors are values, and possible errors are represented in types.
Example:
fn readNumber() !u32 {
return 10;
}The return type !u32 means the function returns either an error or a u32.
When you call it:
const n = try readNumber();semantic analysis checks that try is used in a place where errors can be returned.
For example:
fn readNumber() !u32 {
return 10;
}
pub fn main() void {
const n = try readNumber();
_ = n;
}This is invalid because main returns void, not an error union. There is nowhere for try to return the error.
One fix is:
pub fn main() !void {
const n = try readNumber();
_ = n;
}Now main can return an error.
This is semantic analysis at work. It checks that error flow is valid.
comptime Is Checked
Zig has code that can run at compile time.
Example:
fn double(comptime x: u32) u32 {
return x * 2;
}
const value = double(21);The parameter x must be known at compile time.
If you try to pass a runtime value where a compile-time value is required, semantic analysis rejects it.
Example:
fn double(comptime x: u32) u32 {
return x * 2;
}
pub fn main() void {
var n: u32 = 21;
const value = double(n);
_ = value;
}Here, n is a runtime variable. It cannot be used as a comptime argument.
Semantic analysis enforces the boundary between compile-time and runtime.
Imports Are Resolved
This line is common:
const std = @import("std");The parser sees a declaration and a builtin call.
Semantic analysis resolves what "std" means, loads the imported module, and connects the name std to that module.
Then this works:
std.debug.print("hello\n", .{});The compiler resolves:
std
std.debug
std.debug.printEach step must refer to a valid declaration.
If you misspell something:
std.debig.print("hello\n", .{});semantic analysis rejects it because debig does not exist.
Semantic Analysis Produces Better Internal Code
After semantic analysis, the compiler knows much more than it knew after parsing.
Before semantic analysis:
this is a function call
this is a declaration
this is a block
this is an identifierAfter semantic analysis:
this identifier refers to this declaration
this expression has this type
this function can return these errors
this branch is unreachable
this call requires these argument types
this value is known at compile timeThis information is needed before code generation.
The backend should not have to guess what a name means or what type an expression has. Semantic analysis resolves those questions.
Why Semantic Analysis Is Hard
Semantic analysis is one of the largest parts of a compiler because language meaning is complicated.
It must handle:
types
pointers
slices
arrays
structs
unions
enums
optionals
errors
generics
comptime
imports
control flow
visibility
alignment
calling conventions
target differencesEach feature interacts with the others.
For example, a generic function may depend on a compile-time type, return an error union, allocate memory through an allocator, and behave differently on different targets.
The compiler must still check it precisely.
A Useful Mental Model
Use this model:
Parser: builds the tree.
Semantic analyzer: proves the tree makes sense.Semantic analysis is where Zig becomes strict.
It catches mistakes before runtime. It resolves types before code generation. It enforces explicit error handling. It separates compile-time values from runtime values.
When Zig gives you a type error, name error, return error, comptime error, or invalid function call error, you are usually seeing semantic analysis doing its job.