Bratish Goswami

Personal website and blog

Checking Types in MicroRust

A call to add can resolve correctly and still pass too few arguments. A return statement can parse successfully but return a value its function doesn't allow. If we added strings, we'd also need to check for incompatible arithmetic operands.

Type checking catches these errors by checking operations against the types of their values. MicroRust only needs i32 and unit, the type used for functions such as main that return no value. That's a small enough set to let us follow the checking rules without first building an inference engine.

Why this phase matters

Static type checking reports certain failures before a program runs. Rust's compile-time checks cover memory safety, data races, moves, borrows, and trait obligations. Our checks are limited to arithmetic, calls, and returns, whose types we already know from the source.

Python, Ruby, and JavaScript leave more decisions until an operation runs. That supports flexibility, though an error on a rarely used path may go unnoticed. C checks types statically, but its conversions and undefined behavior limit what those checks guarantee. Java checks its class and interface types before execution, with further runtime checks for casts and dynamic dispatch.

What we are building here

We are implementing semantic analysis for the resolved AST from name resolution. "Semantic analysis" is the catch-all name for checks that need meaning, not just syntax. Type checking is the main semantic check here.

We'll extend the semantics module inside main.rs. Its type_check function takes the ResolvedProgram produced by resolve, so it can use those bindings instead of looking names up again. A successful check produces the TypedProgram that lowering will receive next.

The values in our source language have type i32 or unit. The implementation also needs an internal marker for an expression that exits the function instead of producing a value:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Type {
    I32,
    Unit,
    Never,
}

The source spelling i32 maps to Type::I32. A function with no -> i32 return annotation returns unit, just like Rust's default return type (). Never records a path ended by return; it adds no source syntax. For example, { return 7; } has no value for an enclosing initializer to consume.

Function signatures are collected before checking bodies:

#[derive(Debug, Clone)]
pub struct FnSig {
    pub params: Vec<Type>,
    pub ret: Type,
}

The signatures go in a map keyed by the resolver's symbol IDs. Every parameter and local is explicitly i32, so we can attach types without inference or another name lookup.

The expression checker returns a type. Here is its binary-expression arm:

ExprKind::Binary { left, right, .. } => {
    let a = self.expr(left)?;
    let b = self.expr(right)?;
    expect_type(Type::I32, a, left.span)?;
    expect_type(Type::I32, b, right.span)?;
    if a == Type::Never || b == Type::Never {
        Type::Never
    } else {
        Type::I32
    }
}

Our arithmetic operators all require i32 operands and produce i32. Full Rust has many integer types, inference, trait machinery for operators, casts, and literals whose types remain unresolved until more context is available. Its diagnostics also have to explain failures in that more complicated process.

MicroRust fixes integer literals to i32, which removes that inference work from our checker. expect_type accepts Never in an i32 position because execution cannot reach an operation that would need the missing value. Both operands are still checked for errors.

let statements check the initializer against the declared type:

Stmt::Let { value, .. } => {
    let ty = self.expr(value)?;
    expect_type(Type::I32, ty, value.span)?;
    diverges |= self.exits.contains(&(value.span.start, value.span.end));
}

Name resolution has already decided which binding each use refers to. Here we check the initializer and remember whether it returns early. The exits set records that control-flow fact separately from the expression's type. An explicit tail still has to match its expected type even when unreachable; a return before it does not excuse a mismatched tail. This distinction came out of comparing small cases with rustc.

For a call, we check arity, meaning the number of arguments, as well as their types:

ExprKind::Call { callee, args } => {
    let sig = self.signatures[&self.resolved.names[&expr.span.start]].clone();
    if args.len() != sig.params.len() {
        return Err(Error::new(
            "type",
            expr.span,
            format!(
                "function `{callee}` expects {} arguments but got {}",
                sig.params.len(),
                args.len()
            ),
        ));
    }
    let mut diverges = false;
    for (arg, expected) in args.iter().zip(&sig.params) {
        let actual = self.expr(arg)?;
        expect_type(*expected, actual, arg.span)?;
        diverges |= actual == Type::Never;
    }
    if diverges {
        Type::Never
    } else {
        sig.ret
    }
}

The call arm clones the signature before recursively checking arguments, avoiding a conflicting borrow of the checker. The canonical call is fine:

let result: i32 = add(x, y);

add expects two i32 arguments and returns i32. The local result declares i32, so the initializer matches.

Here is a syntactically valid program that fails semantically:

fn add(a: i32, b: i32) -> i32 {
    return a + b;
}

fn main() {
    let result: i32 = add(1);
}

The parser has no problem with a one-argument call. The resolver finds add. The type checker rejects it:

error: function `add` expects 2 arguments but got 1
 --> example.mrs:6:23
  |
6 |     let result: i32 = add(1);
  |                       ^^^^^^

For returns, the checker remembers the current function's return type:

Stmt::Return { value, span } => {
    let actual = match value {
        Some(value) => self.expr(value)?,
        None => Type::Unit,
    };
    expect_type(self.ret, actual, *span)?;
    diverges = true;
}

The return expression in our addition function passes that check:

fn add(a: i32, b: i32) -> i32 {
    return a + b;
}

If add contained return println!("{}", a);, the check would fail because println! has unit type in MicroRust. With no explicit return type on main, return 5; fails because main is expected to return unit. Rust's main has additional return rules involving the Termination trait, which are outside our subset.

We also need to check whether every path in a non-unit function returns. Without if or while, we can check this with a recursive block walk: an i32 function must either return explicitly or reach an i32 tail expression. A return inside a nested block ends the enclosing function too. Later unreachable statements are still resolved and type-checked, but they cannot restore a path to the end. Supporting if would require checking the branches, either both returning or continuing to a later return. Control-flow graphs make that sort of path analysis easier than working directly on syntax trees.

Statement order still matters. A let binding introduces a local type after its initializer checks, and a return ends execution of the function. Expression statements can discard unit or i32 values. A later lint could warn about an accidentally unused value, as Rust's warnings do; we aren't adding that check here.

There are several reasonable ways to retain the result. Our companion keeps the resolved tree and a side table of expression types, keyed by each expression's (start, end) span:

pub struct TypedProgram {
    pub resolved: ResolvedProgram,
    pub signatures: HashMap<SymbolId, FnSig>,
    pub types: HashMap<(usize, usize), Type>,
}

Both endpoints matter because a binary expression and its left operand can start at the same byte. Lowering uses the annotation to distinguish i32 calls from unit calls. Keeping the types costs memory but avoids recomputing them, a trade-off I'm comfortable with for these small trees.

Java checks calls against declared signatures and overload sets. C checks function signatures too, under rules that have changed across its standards. Python and Ruby generally check arguments at runtime, with optional tools such as mypy and Sorbet providing static checks. JavaScript engines guard many of their optimization assumptions at runtime. Rust relies more heavily on its type checker and trait solver for safety and abstraction.

For our subset, maps and a recursive walk are enough. We check each expression's type against the requirements of its operator, binding annotation, or function signature. Trait solving would require considerably more information and machinery.

The absence of inference is another deliberate simplification. Real Rust could infer this:

let x = 5 + 3;

MicroRust requires:

let x: i32 = 5 + 3;

The annotation supplies the expected type directly. Inference would collect constraints, such as matching the operands of + and satisfying the binding's type, then solve them. Rust limits inference compared with languages such as Haskell, but it still does considerably more than our lookup.

After type checking, our canonical program carries enough information for lowering:

Function add(i32, i32) -> i32
  Return i32
    Binary + : i32
      Local a : i32
      Local b : i32

Function main() -> unit
  Let x : i32 = ...
  Let y : i32 = ...
  Let result : i32 = ...
  Println unit

We keep spans through resolution and type checking so errors can point back to the source: the call for add(1), or the expression for a mismatched return. That saves the reader from searching for the mistake.

The program also needs an entry function. MicroRust requires exactly one main, with no parameters and unit return, as in this example:

fn main() {
    println!("{}", 1);
}

If main takes an argument, ordinary Rust binary rules reject it too. The companion reports:

type error: main must have no parameters and return unit

The check on main concerns the program as a whole and belongs in semantic analysis alongside the expression type checks.

Other function names must be unique too; the resolver already checks this when declaring globals. Stopping at that failure keeps invalid declarations out of later phases and avoids duplicate diagnostics.

Resolution and type checking now reject unknown names, incorrect calls, mismatched initializers, and invalid returns. An accepted a + b means checked integer addition. This is a useful point to test rejected programs too; our canonical example exercises only the successful path.

Run this version

This version extends the semantics module in the same main.rs. It keeps resolution and adds function signatures, expression types, and return checks. Type::Never is the only addition to the syntax types; there is no new source-language syntax.

Keep the same two files:

microrust/
|-- main.rs
`-- example.mrs

If you kept golden.mrs beside these files, leave it there. Replace the contents of main.rs with the listing below. It includes all earlier phases, so don't append the whole listing to your existing file.

main.rs: compiler through type checking

// Chapter 05: add type checking.
use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
    pub start: usize,
    pub end: usize,
}

#[derive(Debug, Clone)]
pub struct Error {
    pub phase: &'static str,
    pub span: Span,
    pub message: String,
}

impl Error {
    pub fn new(phase: &'static str, span: Span, message: impl Into<String>) -> Self {
        Self {
            phase,
            span,
            message: message.into(),
        }
    }

    pub fn render(&self, path: &str, source: &str) -> String {
        let start = self.span.start.min(source.len());
        let prefix = &source[..start];
        let line = prefix.bytes().filter(|b| *b == b'\n').count() + 1;
        let column = prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1;
        let text = source.lines().nth(line - 1).unwrap_or("");
        format!(
            "{}\n --> {path}:{line}:{column}\n{line} | {text}\n  | {}^",
            self,
            " ".repeat(column - 1)
        )
    }
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} error: {}", self.phase, self.message)
    }
}
impl std::error::Error for Error {}

// Later phases will use the source spans retained in the AST.
#[allow(dead_code)]
mod syntax {
    use crate::{Error, Span};

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum TokenKind {
        Fn,
        Let,
        Return,
        Ident,
        Int,
        String,
        I32,
        Plus,
        Minus,
        Star,
        Slash,
        Colon,
        Semicolon,
        Comma,
        LParen,
        RParen,
        LBrace,
        RBrace,
        Arrow,
        Eq,
        Bang,
        Eof,
    }

    #[derive(Debug, Clone)]
    pub struct Token {
        pub kind: TokenKind,
        pub lexeme: String,
        pub span: Span,
    }

    pub fn ident_or_keyword(text: &str) -> TokenKind {
        match text {
            "fn" => TokenKind::Fn,
            "let" => TokenKind::Let,
            "return" => TokenKind::Return,
            "i32" => TokenKind::I32,
            _ => TokenKind::Ident,
        }
    }

    pub fn lex(source: &str) -> Result<Vec<Token>, Error> {
        let bytes = source.as_bytes();
        let mut pos = 0;
        let mut tokens = Vec::new();
        while pos < bytes.len() {
            if bytes[pos].is_ascii_whitespace() {
                pos += 1;
                continue;
            }
            if source[pos..].starts_with("//!")
                || (source[pos..].starts_with("///") && !source[pos..].starts_with("////"))
            {
                return Err(Error::new(
                    "lex",
                    Span {
                        start: pos,
                        end: pos + 3,
                    },
                    "doc comments are outside MicroRust",
                ));
            }
            if source[pos..].starts_with("//") {
                while pos < bytes.len() && bytes[pos] != b'\n' {
                    pos += 1;
                }
                continue;
            }
            let start = pos;
            let c = bytes[pos];
            pos += 1;
            let kind = if c.is_ascii_alphabetic() || c == b'_' {
                while pos < bytes.len()
                    && (bytes[pos].is_ascii_alphanumeric() || bytes[pos] == b'_')
                {
                    pos += 1;
                }
                ident_or_keyword(&source[start..pos])
            } else if c.is_ascii_digit() {
                while pos < bytes.len() && bytes[pos].is_ascii_digit() {
                    pos += 1;
                }
                TokenKind::Int
            } else {
                match c {
                    b'+' => TokenKind::Plus,
                    b'-' if bytes.get(pos) == Some(&b'>') => {
                        pos += 1;
                        TokenKind::Arrow
                    }
                    b'-' => TokenKind::Minus,
                    b'*' => TokenKind::Star,
                    b'/' if bytes.get(pos) == Some(&b'*') => {
                        return Err(Error::new(
                            "lex",
                            Span {
                                start,
                                end: pos + 1,
                            },
                            "block comments are outside MicroRust; use //",
                        ))
                    }
                    b'/' => TokenKind::Slash,
                    b':' => TokenKind::Colon,
                    b';' => TokenKind::Semicolon,
                    b',' => TokenKind::Comma,
                    b'(' => TokenKind::LParen,
                    b')' => TokenKind::RParen,
                    b'{' => TokenKind::LBrace,
                    b'}' => TokenKind::RBrace,
                    b'=' => TokenKind::Eq,
                    b'!' => TokenKind::Bang,
                    b'"' => {
                        while pos < bytes.len() && bytes[pos] != b'"' {
                            if !bytes[pos].is_ascii() || matches!(bytes[pos], b'\\' | b'\n' | b'\r')
                            {
                                return Err(Error::new(
                                    "lex",
                                    Span { start, end: pos },
                                    "only the unescaped format string \"{}\" is supported",
                                ));
                            }
                            pos += 1;
                        }
                        if pos == bytes.len() {
                            return Err(Error::new(
                                "lex",
                                Span { start, end: pos },
                                "unterminated string",
                            ));
                        }
                        pos += 1;
                        TokenKind::String
                    }
                    _ => {
                        let ch = source[start..].chars().next().unwrap();
                        return Err(Error::new(
                            "lex",
                            Span {
                                start,
                                end: start + ch.len_utf8(),
                            },
                            format!("unexpected character '{ch}'"),
                        ));
                    }
                }
            };
            tokens.push(Token {
                kind,
                lexeme: source[start..pos].into(),
                span: Span { start, end: pos },
            });
        }
        tokens.push(Token {
            kind: TokenKind::Eof,
            lexeme: String::new(),
            span: Span {
                start: pos,
                end: pos,
            },
        });
        Ok(tokens)
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum Type {
        I32,
        Unit,
        Never,
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum BinOp {
        Add,
        Sub,
        Mul,
        Div,
    }

    #[derive(Debug, Clone)]
    pub struct Program {
        pub functions: Vec<Function>,
    }

    #[derive(Debug, Clone)]
    pub struct Function {
        pub name: String,
        pub span: Span,
        pub params: Vec<Param>,
        pub return_type: Type,
        pub body: Block,
    }

    #[derive(Debug, Clone)]
    pub struct Param {
        pub name: String,
        pub span: Span,
    }

    #[derive(Debug, Clone)]
    pub struct Block {
        pub statements: Vec<Stmt>,
        pub tail: Option<Box<Expr>>,
        pub span: Span,
    }

    #[derive(Debug, Clone)]
    pub enum Stmt {
        Let {
            name: String,
            span: Span,
            value: Expr,
        },
        Return {
            value: Option<Expr>,
            span: Span,
        },
        Expr(Expr),
    }

    #[derive(Debug, Clone)]
    pub struct Expr {
        pub kind: ExprKind,
        pub span: Span,
    }

    #[derive(Debug, Clone)]
    pub enum ExprKind {
        Int(i32),
        Name(String),
        Binary {
            op: BinOp,
            left: Box<Expr>,
            right: Box<Expr>,
        },
        Call {
            callee: String,
            args: Vec<Expr>,
        },
        Println {
            value: Box<Expr>,
        },
        Block(Block),
    }

    pub struct Parser {
        tokens: Vec<Token>,
        pos: usize,
    }

    impl Parser {
        pub fn new(tokens: Vec<Token>) -> Self {
            Self { tokens, pos: 0 }
        }
        fn peek(&self) -> &Token {
            &self.tokens[self.pos]
        }
        fn advance(&mut self) -> Token {
            let t = self.peek().clone();
            if t.kind != TokenKind::Eof {
                self.pos += 1;
            }
            t
        }
        fn error(&self, message: &str) -> Error {
            Error::new("parse", self.peek().span, message)
        }
        fn eat(&mut self, kind: TokenKind) -> bool {
            if self.peek().kind == kind {
                self.advance();
                true
            } else {
                false
            }
        }
        fn expect(&mut self, kind: TokenKind) -> Result<Token, Error> {
            if self.peek().kind == kind {
                Ok(self.advance())
            } else {
                Err(self.error(&format!("expected {kind:?}, found {:?}", self.peek().kind)))
            }
        }
        fn ident(&mut self) -> Result<Token, Error> {
            let token = self.expect(TokenKind::Ident)?;
            // A restricted scanner must not turn Rust keywords into valid user names.
            const RESERVED: &[&str] = &[
                "_", "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else",
                "enum", "extern", "false", "for", "if", "impl", "in", "loop", "match", "mod",
                "move", "mut", "pub", "ref", "self", "Self", "static", "struct", "super", "trait",
                "true", "type", "unsafe", "use", "where", "while", "abstract", "become", "box",
                "do", "final", "macro", "override", "priv", "typeof", "unsized", "virtual",
                "yield", "try", "gen",
            ];
            if RESERVED.contains(&token.lexeme.as_str()) {
                return Err(Error::new(
                    "parse",
                    token.span,
                    "reserved Rust word is not an identifier",
                ));
            }
            Ok(token)
        }
        pub fn parse(mut self) -> Result<Program, Error> {
            let mut functions = Vec::new();
            while self.peek().kind != TokenKind::Eof {
                functions.push(self.parse_function()?);
            }
            Ok(Program { functions })
        }
        fn parse_function(&mut self) -> Result<Function, Error> {
            self.expect(TokenKind::Fn)?;
            let name = self.ident()?;
            self.expect(TokenKind::LParen)?;
            let mut params = Vec::new();
            while self.peek().kind != TokenKind::RParen {
                let param = self.ident()?;
                self.expect(TokenKind::Colon)?;
                self.expect(TokenKind::I32)?;
                params.push(Param {
                    name: param.lexeme,
                    span: param.span,
                });
                if !self.eat(TokenKind::Comma) {
                    break;
                }
            }
            self.expect(TokenKind::RParen)?;
            let return_type = if self.eat(TokenKind::Arrow) {
                self.expect(TokenKind::I32)?;
                Type::I32
            } else {
                Type::Unit
            };
            let body = self.parse_block()?;
            Ok(Function {
                name: name.lexeme,
                span: name.span,
                params,
                return_type,
                body,
            })
        }
        fn parse_block(&mut self) -> Result<Block, Error> {
            let start = self.expect(TokenKind::LBrace)?.span.start;
            let mut statements = Vec::new();
            let mut tail = None;
            while self.peek().kind != TokenKind::RBrace {
                if self.peek().kind == TokenKind::Eof {
                    return Err(self.error("unclosed block"));
                }
                if self.eat(TokenKind::Let) {
                    let name = self.ident()?;
                    self.expect(TokenKind::Colon)?;
                    self.expect(TokenKind::I32)?;
                    self.expect(TokenKind::Eq)?;
                    let value = self.parse_expr(0)?;
                    self.expect(TokenKind::Semicolon)?;
                    statements.push(Stmt::Let {
                        name: name.lexeme,
                        span: name.span,
                        value,
                    });
                } else if self.peek().kind == TokenKind::Return {
                    let span = self.advance().span;
                    let value = if self.peek().kind == TokenKind::Semicolon {
                        None
                    } else {
                        Some(self.parse_expr(0)?)
                    };
                    self.expect(TokenKind::Semicolon)?;
                    statements.push(Stmt::Return { value, span });
                } else {
                    let value = self.parse_expr(0)?;
                    if self.eat(TokenKind::Semicolon) {
                        statements.push(Stmt::Expr(value));
                    } else {
                        tail = Some(Box::new(value));
                        break;
                    }
                }
            }
            let end = self.expect(TokenKind::RBrace)?.span.end;
            Ok(Block {
                statements,
                tail,
                span: Span { start, end },
            })
        }
        fn current_infix(&self) -> Option<(BinOp, u8, u8)> {
            match self.peek().kind {
                TokenKind::Plus => Some((BinOp::Add, 20, 21)),
                TokenKind::Minus => Some((BinOp::Sub, 20, 21)),
                TokenKind::Star => Some((BinOp::Mul, 30, 31)),
                TokenKind::Slash => Some((BinOp::Div, 30, 31)),
                _ => None,
            }
        }
        fn parse_expr(&mut self, min_bp: u8) -> Result<Expr, Error> {
            let mut left = self.parse_prefix()?;
            while let Some((op, left_bp, right_bp)) = self.current_infix() {
                if left_bp < min_bp {
                    break;
                }
                self.advance();
                let right = self.parse_expr(right_bp)?;
                let span = Span {
                    start: left.span.start,
                    end: right.span.end,
                };
                left = Expr {
                    kind: ExprKind::Binary {
                        op,
                        left: Box::new(left),
                        right: Box::new(right),
                    },
                    span,
                };
            }
            Ok(left)
        }
        fn parse_prefix(&mut self) -> Result<Expr, Error> {
            match self.peek().kind {
                TokenKind::Int => {
                    let t = self.advance();
                    let n = t.lexeme.parse::<i32>().map_err(|_| {
                        Error::new("parse", t.span, "integer literal is outside 0..=2147483647")
                    })?;
                    Ok(Expr {
                        kind: ExprKind::Int(n),
                        span: t.span,
                    })
                }
                TokenKind::Ident => {
                    let name = self.ident()?;
                    let kind = if self.eat(TokenKind::Bang) {
                        if name.lexeme != "println" {
                            return Err(Error::new(
                                "parse",
                                name.span,
                                "only println! is supported",
                            ));
                        }
                        self.expect(TokenKind::LParen)?;
                        let format = self.expect(TokenKind::String)?;
                        if format.lexeme != "\"{}\"" {
                            return Err(Error::new(
                                "parse",
                                format.span,
                                "expected format string \"{}\"",
                            ));
                        }
                        self.expect(TokenKind::Comma)?;
                        let value = self.parse_expr(0)?;
                        self.expect(TokenKind::RParen)?;
                        ExprKind::Println {
                            value: Box::new(value),
                        }
                    } else if self.eat(TokenKind::LParen) {
                        let mut args = Vec::new();
                        while self.peek().kind != TokenKind::RParen {
                            args.push(self.parse_expr(0)?);
                            if !self.eat(TokenKind::Comma) {
                                break;
                            }
                        }
                        self.expect(TokenKind::RParen)?;
                        ExprKind::Call {
                            callee: name.lexeme,
                            args,
                        }
                    } else {
                        ExprKind::Name(name.lexeme)
                    };
                    let end = self.tokens[self.pos - 1].span.end;
                    Ok(Expr {
                        kind,
                        span: Span {
                            start: name.span.start,
                            end,
                        },
                    })
                }
                TokenKind::LParen => {
                    self.advance();
                    let expr = self.parse_expr(0)?;
                    self.expect(TokenKind::RParen)?;
                    Ok(expr)
                }
                TokenKind::LBrace => {
                    let block = self.parse_block()?;
                    let span = block.span;
                    Ok(Expr {
                        kind: ExprKind::Block(block),
                        span,
                    })
                }
                _ => Err(self.error("expected expression")),
            }
        }
    }
}

#[allow(dead_code)]
mod semantics {
    use crate::syntax::{Block, Expr, ExprKind, Program, Stmt, Type};
    use crate::{Error, Span};
    use std::collections::{HashMap, HashSet};

    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    pub struct SymbolId(pub usize);

    #[derive(Debug, Clone)]
    pub struct Symbol {
        pub name: String,
        pub kind: SymbolKind,
        pub span: Span,
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum SymbolKind {
        Function,
        Local,
        Parameter,
    }

    pub struct ResolvedProgram {
        pub ast: Program,
        pub symbols: Vec<Symbol>,
        pub names: HashMap<usize, SymbolId>,
        pub globals: HashMap<String, SymbolId>,
    }

    struct Resolver {
        symbols: Vec<Symbol>,
        names: HashMap<usize, SymbolId>,
        globals: HashMap<String, SymbolId>,
        scopes: Vec<HashMap<String, SymbolId>>,
    }

    pub fn resolve(ast: Program) -> Result<ResolvedProgram, Error> {
        let mut r = Resolver {
            symbols: vec![],
            names: HashMap::new(),
            globals: HashMap::new(),
            scopes: vec![],
        };
        for f in &ast.functions {
            if r.globals.contains_key(&f.name) {
                return Err(Error::new(
                    "resolve",
                    f.span,
                    format!("duplicate function `{}`", f.name),
                ));
            }
            let id = r.declare(&f.name, SymbolKind::Function, f.span);
            r.globals.insert(f.name.clone(), id);
        }
        for f in &ast.functions {
            r.scopes.push(HashMap::new());
            for p in &f.params {
                if r.scopes.last().unwrap().contains_key(&p.name) {
                    return Err(Error::new("resolve", p.span, "duplicate parameter"));
                }
                let id = r.declare(&p.name, SymbolKind::Parameter, p.span);
                r.scopes.last_mut().unwrap().insert(p.name.clone(), id);
            }
            r.block(&f.body)?;
            r.scopes.pop();
        }
        Ok(ResolvedProgram {
            ast,
            symbols: r.symbols,
            names: r.names,
            globals: r.globals,
        })
    }

    impl Resolver {
        fn declare(&mut self, name: &str, kind: SymbolKind, span: Span) -> SymbolId {
            let id = SymbolId(self.symbols.len());
            self.symbols.push(Symbol {
                name: name.into(),
                kind,
                span,
            });
            self.names.insert(span.start, id);
            id
        }
        fn lookup_local(&self, name: &str) -> Option<SymbolId> {
            self.scopes
                .iter()
                .rev()
                .find_map(|scope| scope.get(name).copied())
        }
        fn block(&mut self, block: &Block) -> Result<(), Error> {
            self.scopes.push(HashMap::new());
            for stmt in &block.statements {
                match stmt {
                    Stmt::Let { name, span, value } => {
                        self.expr(value)?;
                        let id = self.declare(name, SymbolKind::Local, *span);
                        self.scopes.last_mut().unwrap().insert(name.clone(), id);
                    }
                    Stmt::Return { value, .. } => {
                        if let Some(value) = value {
                            self.expr(value)?;
                        }
                    }
                    Stmt::Expr(value) => self.expr(value)?,
                }
            }
            if let Some(tail) = &block.tail {
                self.expr(tail)?;
            }
            self.scopes.pop();
            Ok(())
        }
        fn expr(&mut self, expr: &Expr) -> Result<(), Error> {
            match &expr.kind {
                ExprKind::Int(_) => {}
                ExprKind::Name(name) => {
                    let id = self.lookup_local(name).ok_or_else(|| {
                        Error::new(
                            "resolve",
                            expr.span,
                            format!("cannot find local `{name}` in this scope"),
                        )
                    })?;
                    self.names.insert(expr.span.start, id);
                }
                ExprKind::Binary { left, right, .. } => {
                    self.expr(left)?;
                    self.expr(right)?;
                }
                ExprKind::Call { callee, args } => {
                    if self.lookup_local(callee).is_some() {
                        return Err(Error::new(
                            "resolve",
                            expr.span,
                            format!("`{callee}` is a local, not a callable function"),
                        ));
                    }
                    let id = self.globals.get(callee).copied().ok_or_else(|| {
                        Error::new(
                            "resolve",
                            expr.span,
                            format!("cannot find function `{callee}`"),
                        )
                    })?;
                    self.names.insert(expr.span.start, id);
                    for arg in args {
                        self.expr(arg)?;
                    }
                }
                ExprKind::Println { value } => self.expr(value)?,
                ExprKind::Block(block) => self.block(block)?,
            }
            Ok(())
        }
    }

    #[derive(Debug, Clone)]
    pub struct FnSig {
        pub params: Vec<Type>,
        pub ret: Type,
    }

    pub struct TypedProgram {
        pub resolved: ResolvedProgram,
        pub signatures: HashMap<SymbolId, FnSig>,
        pub types: HashMap<(usize, usize), Type>,
    }

    pub fn type_check(resolved: ResolvedProgram) -> Result<TypedProgram, Error> {
        let main = resolved
            .ast
            .functions
            .iter()
            .find(|f| f.name == "main")
            .ok_or_else(|| Error::new("type", Span { start: 0, end: 0 }, "missing fn main()"))?;
        if !main.params.is_empty() || main.return_type != Type::Unit {
            return Err(Error::new(
                "type",
                main.span,
                "main must have no parameters and return unit",
            ));
        }
        let mut signatures = HashMap::new();
        for f in &resolved.ast.functions {
            if f.params.len() > 6 {
                return Err(Error::new(
                    "type",
                    f.span,
                    "MicroRust supports at most six parameters",
                ));
            }
            signatures.insert(
                resolved.globals[&f.name],
                FnSig {
                    params: vec![Type::I32; f.params.len()],
                    ret: f.return_type,
                },
            );
        }
        let mut c = Checker {
            resolved: &resolved,
            signatures: &signatures,
            types: HashMap::new(),
            exits: HashSet::new(),
            ret: Type::Unit,
        };
        for f in &resolved.ast.functions {
            c.ret = f.return_type;
            let actual = c.block(&f.body)?;
            expect_type(f.return_type, actual, f.body.span)?;
        }
        let types = c.types;
        Ok(TypedProgram {
            resolved,
            signatures,
            types,
        })
    }

    fn expect_type(expected: Type, actual: Type, span: Span) -> Result<(), Error> {
        if actual == expected || actual == Type::Never {
            Ok(())
        } else {
            Err(Error::new(
                "type",
                span,
                format!("expected {expected:?}, found {actual:?}"),
            ))
        }
    }

    struct Checker<'a> {
        resolved: &'a ResolvedProgram,
        signatures: &'a HashMap<SymbolId, FnSig>,
        types: HashMap<(usize, usize), Type>,
        exits: HashSet<(usize, usize)>,
        ret: Type,
    }

    impl Checker<'_> {
        fn block(&mut self, block: &Block) -> Result<Type, Error> {
            let mut diverges = false;
            for stmt in &block.statements {
                match stmt {
                    Stmt::Let { value, .. } => {
                        let ty = self.expr(value)?;
                        expect_type(Type::I32, ty, value.span)?;
                        diverges |= self.exits.contains(&(value.span.start, value.span.end));
                    }
                    Stmt::Return { value, span } => {
                        let actual = match value {
                            Some(value) => self.expr(value)?,
                            None => Type::Unit,
                        };
                        expect_type(self.ret, actual, *span)?;
                        diverges = true;
                    }
                    Stmt::Expr(value) => {
                        self.expr(value)?;
                        diverges |= self.exits.contains(&(value.span.start, value.span.end));
                    }
                }
            }
            let ty = if let Some(expr) = &block.tail {
                let ty = self.expr(expr)?;
                diverges |= self.exits.contains(&(expr.span.start, expr.span.end));
                ty
            } else if diverges {
                Type::Never
            } else {
                Type::Unit
            };
            if diverges {
                self.exits.insert((block.span.start, block.span.end));
            }
            // An explicit tail still has to type-check, even when unreachable.
            Ok(ty)
        }
        fn expr(&mut self, expr: &Expr) -> Result<Type, Error> {
            let ty = match &expr.kind {
                ExprKind::Int(_) | ExprKind::Name(_) => Type::I32,
                ExprKind::Binary { left, right, .. } => {
                    let a = self.expr(left)?;
                    let b = self.expr(right)?;
                    expect_type(Type::I32, a, left.span)?;
                    expect_type(Type::I32, b, right.span)?;
                    if a == Type::Never || b == Type::Never {
                        Type::Never
                    } else {
                        Type::I32
                    }
                }
                ExprKind::Call { callee, args } => {
                    let sig = self.signatures[&self.resolved.names[&expr.span.start]].clone();
                    if args.len() != sig.params.len() {
                        return Err(Error::new(
                            "type",
                            expr.span,
                            format!(
                                "function `{callee}` expects {} arguments but got {}",
                                sig.params.len(),
                                args.len()
                            ),
                        ));
                    }
                    let mut diverges = false;
                    for (arg, expected) in args.iter().zip(&sig.params) {
                        let actual = self.expr(arg)?;
                        expect_type(*expected, actual, arg.span)?;
                        diverges |= actual == Type::Never;
                    }
                    if diverges {
                        Type::Never
                    } else {
                        sig.ret
                    }
                }
                ExprKind::Println { value } => {
                    let ty = self.expr(value)?;
                    expect_type(Type::I32, ty, value.span)?;
                    if ty == Type::Never {
                        Type::Never
                    } else {
                        Type::Unit
                    }
                }
                ExprKind::Block(block) => self.block(block)?,
            };
            let exits = match &expr.kind {
                ExprKind::Binary { left, right, .. } => {
                    self.exits.contains(&(left.span.start, left.span.end))
                        || self.exits.contains(&(right.span.start, right.span.end))
                }
                ExprKind::Call { args, .. } => args
                    .iter()
                    .any(|arg| self.exits.contains(&(arg.span.start, arg.span.end))),
                ExprKind::Println { value } => {
                    self.exits.contains(&(value.span.start, value.span.end))
                }
                ExprKind::Block(block) => self.exits.contains(&(block.span.start, block.span.end)),
                _ => false,
            };
            if exits {
                self.exits.insert((expr.span.start, expr.span.end));
            }
            self.types.insert((expr.span.start, expr.span.end), ty);
            Ok(ty)
        }
    }
}

fn expression(expr: &syntax::Expr) -> String {
    use syntax::ExprKind;
    match &expr.kind {
        ExprKind::Int(value) => value.to_string(),
        ExprKind::Name(name) => name.clone(),
        ExprKind::Binary { op, left, right } => {
            let symbol = match op {
                syntax::BinOp::Add => "+",
                syntax::BinOp::Sub => "-",
                syntax::BinOp::Mul => "*",
                syntax::BinOp::Div => "/",
            };
            format!("({} {symbol} {})", expression(left), expression(right))
        }
        ExprKind::Call { callee, args } => {
            let args = args.iter().map(expression).collect::<Vec<_>>().join(", ");
            format!("{callee}({args})")
        }
        ExprKind::Println { value } => format!("println({})", expression(value)),
        ExprKind::Block(block) => format!("{{ {} }}", statements(block).join(" ")),
    }
}

fn statements(block: &syntax::Block) -> Vec<String> {
    let mut lines = Vec::new();
    for statement in &block.statements {
        let line = match statement {
            syntax::Stmt::Let { name, value, .. } => {
                format!("let {name}: i32 = {};", expression(value))
            }
            syntax::Stmt::Return { value, .. } => match value {
                Some(value) => format!("return {};", expression(value)),
                None => "return;".into(),
            },
            syntax::Stmt::Expr(value) => format!("{};", expression(value)),
        };
        lines.push(line);
    }
    if let Some(tail) = &block.tail {
        lines.push(format!("tail {}", expression(tail)));
    }
    lines
}

fn print_tree(program: &syntax::Program) {
    for function in &program.functions {
        let params = function
            .params
            .iter()
            .map(|param| format!("{}: i32", param.name))
            .collect::<Vec<_>>()
            .join(", ");
        println!(
            "fn {}({params}) -> {:?}",
            function.name, function.return_type
        );
        for line in statements(&function.body) {
            println!("  {line}");
        }
    }
}

fn main() {
    if let Err(error) = run() {
        eprintln!("{error}");
        std::process::exit(1);
    }
}

fn run() -> Result<(), String> {
    const USAGE: &str = "usage: microrustc <tokens|ast|resolve|check> FILE";
    let mut args = std::env::args().skip(1);
    let mode = args.next().ok_or(USAGE)?;
    let path = args.next().ok_or(USAGE)?;
    if !["tokens", "ast", "resolve", "check"].contains(&mode.as_str()) || args.next().is_some() {
        return Err(USAGE.into());
    }
    let source = std::fs::read_to_string(&path).map_err(|error| format!("{path}: {error}"))?;
    let render = |error: Error| error.render(&path, &source);
    let tokens = syntax::lex(&source).map_err(render)?;
    if mode == "tokens" {
        print_tokens(&tokens);
        return Ok(());
    }
    let ast = syntax::Parser::new(tokens).parse().map_err(render)?;
    if mode == "ast" {
        print_tree(&ast);
        return Ok(());
    }
    let resolved = semantics::resolve(ast).map_err(render)?;
    if mode == "check" {
        semantics::type_check(resolved).map_err(render)?;
        println!("checked {path}");
        return Ok(());
    }
    print_symbols(&resolved);
    Ok(())
}

fn print_tokens(tokens: &[syntax::Token]) {
    for token in tokens {
        println!(
            "{:?} {:?} {}..{}",
            token.kind, token.lexeme, token.span.start, token.span.end
        );
    }
}

fn print_symbols(program: &semantics::ResolvedProgram) {
    for (index, symbol) in program.symbols.iter().enumerate() {
        println!("#{index} {:?} {}", symbol.kind, symbol.name);
    }
}

The check command runs the entire frontend in order. A parsing error stops before resolution, and an unknown name stops before type checking. On success, TypedProgram holds the tree and the information that lowering will use.

Keep the same example.mrs from the resolver chapter. This is the complete input if you're joining here:

fn add(a: i32, b: i32) -> i32 {
    return a + b;
}

fn main() {
    let x: i32 = 5 + 3;
    let y: i32 = x * 2;
    let result: i32 = add(x, y);
    println!("{}", result);
}

This remains valid Rust, with .mrs marking it as input to our compiler. From the folder containing microrust, run:

cd microrust
rustc main.rs -o microrustc
./microrustc check example.mrs

Skip the first command if you're already in microrust. Rebuilding updates the same executable. The output should be:

checked example.mrs

That line means the frontend accepted the program. Nothing has run the add function yet. The earlier tokens, ast, and resolve commands still let us inspect individual stages.

Change add(x, y) to add(x) and compare ./microrustc resolve example.mrs with ./microrustc check example.mrs. Resolution succeeds; checking reports that add expects two arguments. Restore the second argument before continuing.

Another useful test is to replace add(x, y) with println!("{}", x) in the initializer for result. The name and syntax checks pass, but the initializer has unit type where i32 is required. These failures help check that each phase is doing the work we intended.

The typed AST still follows the shape of the source. In the next post, we'll translate that tree into instructions with explicit temporaries and control flow. That will give the optimizer and backend a simpler representation to work with.