Chapter 06 a mistake refused before it runs

Types, and Inferring Them

Right now izvor will happily add a number to a boolean and hand you back garbage. This chapter gives every expression a type, works that type out bottom up with no annotations required, and refuses to run a program where the types do not make sense. Eleven steps, written ahead of the code, so treat every listing as an instruction, not a transcript.

x Int + y Bool
↓  check_node() on the parent, after both children are done
Int + Bool TYPE_ERROR
x and y already know their own types before the + node is ever asked for its type. That order, children first, is the entire idea. The parent just asks two questions that are already answered and decides what it is allowed to do with the pair of answers.
Files you touch10
Lines you write~230
New functions7
Traps3

No code in this step. Before you write a type checker you need to be able to say, out loud, what problem it solves that the parser does not already solve.

A type is a promise about the shape of a value. 10 is an Int, which promises you can add it, subtract it, multiply it. true is a Bool, which promises exactly the opposite: nothing about it is a quantity. The parser has already checked that x + y is grammatically a sound expression, an identifier, a plus sign, another identifier. It has no way to check whether adding those two particular things means anything, because the parser works one token at a time and never asks what a name actually holds.

Take this program.

a program that parses cleanly and means nothing3 lines
1
let x = 10;
2
let y = true;
3
print x + y;

Three legal statements in a row, and the third one asks the machine to add a number to a truth value. Nothing about the grammar objects.

Right now that program parses without a complaint, gets handed to the evaluator, and the evaluator does whatever adding a long to whatever a boolean happens to be represented as does. That is not a crash you can rely on. It is a wrong answer that looks like a right one, and those are the expensive kind, because nothing tells you it happened.

So a new pass runs after parsing and before evaluation: walk the finished tree once, work out the type of every piece of it, and if two pieces get combined in a way that does not make sense, stop and say so. Nothing after this pass has to worry about types again. The evaluator can go on assuming every addition really is two numbers, because by the time it runs, that has already been checked.

Why this is not the parser's job

You could imagine folding this into the grammar somehow, but a grammar describes shape, not meaning. x + y has exactly one shape no matter what x and y turn out to be. Whether that shape is sound depends on facts the parser never collects, namely what each name was declared as, several lines earlier. That is a different kind of question, which is why it gets a different pass.

Checkpoint

A new file, a new header, and one enum. izvor gets exactly four types for now.

src/type.h · new file

src/type.hnew file, 15 lines
1
/* Types: the four shapes an izvor value can have, and the sentinel
   the checker uses to stop one mistake from becoming ten. */
2
3
#ifndef IZVOR_TYPE_H
4
#define IZVOR_TYPE_H
5
6
typedef enum {

Four cases today. Chapter 7's TYPE_FN is not here yet, on purpose. Adding it now with nothing that produces it would just be a dead case waiting for a compiler warning.

7
    TYPE_INT,

What 10 is, and what + - * / require on both sides.

8
    TYPE_BOOL,

What true and false are. This chapter adds them as a second kind of value specifically so there is a second type to get wrong.

9
    TYPE_VOID,

The type of a statement that produces no value: let, var, an assignment, print. Nothing downstream can use a let statement as an expression, and giving statements a real type instead of skipping them keeps the checking function returning one honest answer for every node, with no special case for "this kind has no type".

10
    TYPE_ERROR

Not a real type. A sentinel meaning "already reported, do not report again". Step 9 is entirely about this one value.

11
} TypeKind;
12
13
const char *type_name(TypeKind type);

Declared here, written in step 3.

14
15
#endif

TYPE_ERROR is the one that is not obvious. Say the checker only had three types and no sentinel. It sees x + y where x is already wrong for some earlier reason, reports that x's addition does not type check, and has to pick some type for the whole expression anyway so the pass can keep going. Pick TYPE_INT and the next line that uses the result wrongly also gets reported, for a mistake that is really the same mistake. One typo at the top of a file would print ten error messages by the bottom of it. TYPE_ERROR is a type that means "I already told you about this one", and every rule from here on checks for it first.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test

This is supposed to fail. Nothing includes type.h yet and nothing builds type.c, so the Makefile does not even know the file exists. That is fine. It exists for the next step to fill in.

Same discipline as token_type_name back in chapter 4: a switch with no default:, so forgetting a case is a build failure and not a silent gap.

src/type.c · new file, plus one line in the Makefile

src/type.cnew file, first 14 lines
1
/* Type checking: a bottom-up pass that assigns every node a TypeKind
   and refuses a program whose types do not agree. */
2
3
#include <string.h>

For memcmp, used twice later in this file: once to look up a name's type, once to read a type annotation like Int off the source text.

4
#include "ast.h"
5
#include "diag.h"
6
#include "type.h"
7
8
const char *type_name(TypeKind type) {

Somebody reads this string in an error like "found Bool", so it names the type the way the source spells it, capitalized, not the way the enum spells it.

9
    switch (type) {
10
    case TYPE_INT:  return "Int";
11
    case TYPE_BOOL: return "Bool";
12
    case TYPE_VOID: return "Void";
13
    case TYPE_ERROR: return "<error>";

This one should never actually reach a person, since a node already carrying TYPE_ERROR is not supposed to trigger a fresh message. Angle brackets mark it as a value that leaked somewhere it should not have, the same way <input> marks a path that was never set.

14
    }

No return "???"; after the switch this time, unlike token_type_name. TokenType there had to handle a value read straight off untrusted bytes in a file, so a fallback was honest. TypeKind only ever holds one of the four values this same file hands out, so if the switch does not cover every case, the function should fail to compile rather than quietly return nothing. Close the function with nothing after the switch and let the missing-return warning, promoted to an error under -Werror, be the second layer of protection under the missing-case warning.

Makefile · the SRC := line

Makefile1 line changed
1
SRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.c
2
SRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.c src/type.c

Every test binary links against this list. Leave type.c off it and every test that touches the checker fails to link, with an error naming type_check_program or type_name as undefined. That is a linker problem wearing a compiler problem's clothes, worth recognizing on sight.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

Clean build. type.c compiles and links, and nothing calls it yet.

A type checker with only one type has nothing to disagree about. true and false are new tokens, a new keyword check in the lexer, and a new leaf kind in the AST.

src/lexer.h · inside the typedef enum, before TOK_EOF

src/lexer.hadd 2 lines
1
    TOK_VAR,
2
    TOK_TRUE,

Same spot every earlier keyword landed in: right before the pair that means "not a real piece of syntax".

3
    TOK_FALSE,
4
    TOK_EOF,

src/lexer.c · inside token_type_name, before the TOK_EOF case · and inside lexer_next's identifier branch, alongside the existing word_is checks

src/lexer.cadd 4 lines to token_type_name, 2 to lexer_next
1
    case TOK_TRUE:
2
        return "TRUE";
3
    case TOK_FALSE:
4
        return "FALSE";
src/lexer.c · lexer_nextadd 2 lines, before the fallback IDENT return
1
        if (word_is(lx, start, "true"))  return make_token(lx, TOK_TRUE,  start);

Same shape as the let, var, print checks chapter 4 already wrote. Maximal munch already handles the trap here for free: truest eats all six characters first, fails word_is on length, and comes back as an ordinary IDENT.

2
        if (word_is(lx, start, "false")) return make_token(lx, TOK_FALSE, start);

src/ast.h · at the top, and inside the typedef enum for NodeType

src/ast.hadd 1 include, 1 enum case
1
#include "type.h"

Placed with the other #include, above the NodeType enum. Node is about to carry a TypeKind field in step 6, so ast.h needs to know what that name means. type.h does not include ast.h back: it has no reason to know what a Node is, and a header including its own includer is how you get a cycle.

2
    NODE_BINARY,
3
    NODE_BOOL,

A leaf, like NODE_NUMBER. It carries its value directly and has no children to recurse into.

src/ast.h · inside the union as, next to long number;

src/ast.hadd 1 line + 1 include
1
    long number;
2
    bool boolean;

A plain scalar sitting in the union next to number, the same way number sits there for NODE_NUMBER. Needs #include <stdbool.h> added at the top of ast.h, since nothing there has needed bool until now.

src/ast.h and src/ast.c · alongside ast_number

src/ast.hadd 1 line
1
Node *ast_number(long value);
2
Node *ast_bool(bool value);

No offset parameter. Chapter 5 deliberately kept positions out of the constructors and added the at wrapper in parser.c instead, which stamps the offset on whatever it is handed and passes it straight through. Give the new one the same shape as the old one, and let at do the positioning.

src/ast.cadd 8 lines, mirroring ast_number
1
Node *ast_bool(bool value) {
2
    Node *node = new_node(NODE_BOOL);
3
    if (node == NULL) {
4
        return NULL;
5
    }
6
    node->offset = offset;
7
    node->as.boolean = value;
8
    return node;
9
}

src/ast.c · inside ast_free's switch, next to NODE_NUMBER

src/ast.cadd 2 lines
1
    case NODE_NUMBER:
2
    case NODE_BOOL:

Both leaves, both nothing to recurse into, so they share one break. Falling one case into another on purpose, with no code between them, is the one time a missing break is the point rather than a bug.

3
        break;

Checkpoint

$ make EXTRA_CFLAGS=-Werror test

This one fails too, and it should fail loudly: the parser has never heard of TOK_TRUE or NODE_BOOL, so nothing calls ast_bool yet and clang warns that it is dead. Step 5 is what wires it up.

Two more branches in parse_factor, the same shape as the number check right above them.

src/parser.c · inside parse_factor, directly below the TOK_NUMBER check, above the TOK_LPAREN check

src/parser.cadd 8 lines
1
    if (parser_check(p, TOK_NUMBER)) {
2
        /* ...unchanged... */
3
    }
4
    if (parser_check(p, TOK_TRUE)) {

parser_check rather than parser_match here because the offset the node needs to record is the token's own start, and that is easiest to read off p->current before advancing past it.

5
        long offset = p->current.start - p->lexer.src;
6
        parser_advance(p);
7
        return at(ast_bool(true), offset);
8
    }
9
    if (parser_check(p, TOK_FALSE)) {
10
        long offset = p->current.start - p->lexer.src;
11
        parser_advance(p);
12
        return at(ast_bool(false), offset);
13
    }

Notice what is not here: no case in parse_term, no case in parse_expression. A boolean is a factor, exactly like a number or a parenthesized group, so once parse_factor can produce one, 1 + true already parses. It should. Whether that particular addition means anything is not the parser's question. That is next chapter's, sorry, this chapter's whole point, and it is exactly why the checking pass has to exist as a separate step instead of being folded in here.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

izvor -e "true" now parses. It still evaluates and prints garbage if you run it, because eval has never heard of NODE_BOOL and the type checker that will refuse to reach that point does not exist until step 7. That gap is expected and closes by the end of this chapter.

The checker needs somewhere to write down what it worked out, so a later step of the same walk, and later passes entirely, can read the answer instead of recomputing it.

src/ast.h · in struct Node, directly below the offset field chapter 5 added, above the union as

src/ast.hadd 1 line
1
struct Node {
2
    NodeType type;
3
    long offset;
4
    TypeKind vtype;

Not called type. Keep reading.

5
    union { ... } as;
6
};
Trap · the field you were told to add does not fit

If you went looking for a field to hold this literally called type, you would have run into a wall: Node already has one, NodeType type;, sitting there since chapter 1. C does not allow two members of the same struct to share a name, so TypeKind type; right under it does not give you a new field. It gives you a compile error, and not a helpful one. Depending on your compiler you get something like duplicate member 'type' pointing at the second declaration, which is at least honest about what happened.

There is a real distinction being papered over if you try to force one name onto both. node->type answers "what kind of syntax is this", fixed the moment the parser built the node and never touched again. node->vtype answers "what kind of value does this produce", unknown until this pass runs and possibly different for the same NodeType in two different programs, since a NODE_IDENT reading an Int variable and one reading a Bool variable are the same syntax tag with two different answers. Two questions, two fields, two names.

Every constructor that already exists, ast_number, ast_unary, ast_binary, and the ones chapter 5 added for statements, leaves vtype untouched. That is fine. Nothing reads it until the checking pass in the next step runs and fills it in, node by node, on its way through the tree.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

One function, check_node, shaped exactly like ast_free and eval already are: a switch over node->type, no default:, recurse into children before deciding anything about the parent. This step writes the leaves. Step 8 writes the two cases that actually combine things.

src/type.c · below the #include block from step 3

First, a place to remember what each name was declared as. Chapter 5's environment stores a name's runtime value for the evaluator. This pass needs a name's type, which is a different fact, known earlier, and thrown away once the program runs. Rather than teach env.c a second job, this file keeps its own small table, and makes the exact same call chapter 5 made for the evaluator's environment: one flat array, linear lookup, capacity 256. A hash table is still not worth it. A real program has dozens of names in scope, not thousands.

src/type.cadd 34 lines
1
typedef struct {
2
    const char *name;
3
    int length;
4
    TypeKind vtype;
5
} TypeBinding;
6
7
#define MAX_TYPE_BINDINGS 256
8
static TypeBinding bindings[MAX_TYPE_BINDINGS];
9
static int binding_count = 0;

File-scope static, so this table lives for as long as the process does, not just for one call. Step 11 comes back to why that needs one line of care.

10
11
static void record_type(const char *name, int length, TypeKind vtype) {

Called once per let or var, after its initializer has already been checked.

12
    for (int i = 0; i < binding_count; i++) {
13
        if (bindings[i].length == length &&
14
            memcmp(bindings[i].name, name, (size_t)length) == 0) {

Length before bytes, same trap as chapter 4's word_is. A name is a pointer plus a length with no terminating zero, so memcmp alone, without checking the lengths match first, would say total and total_count agree on their first six characters and call that a match.

15
            bindings[i].vtype = vtype;
16
            return;
17
        }
18
    }
19
    if (binding_count < MAX_TYPE_BINDINGS) {

Re-declaring a name inside the same scope is chapter 8's error to catch, once sema.c exists to own that rule. For now, a second let x just overwrites the first entry above, and a program that runs out of the 256 slots silently stops recording new ones rather than crashing. Neither is a real answer. Both are honest about not being sema.c's job yet.

20
        bindings[binding_count].name = name;
21
        bindings[binding_count].length = length;
22
        bindings[binding_count].vtype = vtype;
23
        binding_count++;
24
    }
25
}
26
27
static TypeKind lookup_type(const char *name, int length,

Takes src and offset too, because an undeclared name is itself an error this function has to report, not just a value it fails to find.

28
                                const char *src, long offset) {
29
    for (int i = 0; i < binding_count; i++) {
30
        if (bindings[i].length == length &&
31
            memcmp(bindings[i].name, name, (size_t)length) == 0) {
32
            return bindings[i].vtype;
33
        }
34
    }
35
    diag_error(src, offset, "'%.*s' is not declared", length, name);

%.*s prints exactly length bytes starting at name, which is the only correct way to print a name that is a slice of the source rather than its own zero-terminated string.

36
    return TYPE_ERROR;
37
}

Now the walk itself, starting with the three leaf cases: a node with no children, whose type is either fixed by what kind of node it is, or found by asking the table above.

src/type.cadd 14 lines, the start of check_node
1
static TypeKind check_node(Node *node, const char *src) {
2
    TypeKind result;
3
    switch (node->type) {
4
    case NODE_NUMBER:

A number literal is always an Int. Nothing to check, nothing to recurse into.

5
        result = TYPE_INT;
6
        break;
7
    case NODE_BOOL:

Same idea. It does not matter whether the value underneath is true or false, only that a NODE_BOOL is a Bool.

8
        result = TYPE_BOOL;
9
        break;
10
    case NODE_IDENT:

The only case so far that is not a straight leaf answer: it asks the table what this name was last recorded as.

11
        result = lookup_type(node->as.ident.name, node->as.ident.length,
12
                              src, node->offset);
13
        break;
14
    /* NODE_UNARY, NODE_BINARY: step 8.
       NODE_LET, NODE_VAR, NODE_ASSIGN, NODE_PRINT, NODE_EXPR_STMT: step 9. */

The function is not finished, on purpose, and will not compile clean yet: a switch over NodeType with only three of the ten cases present under -Werror is exactly the incomplete-switch warning doing its job. Step 8 adds the two that actually combine two types into a decision, which is the interesting part of this whole chapter.

One more piece before that: the function a caller actually reaches for. It resets the table, then checks every top-level statement in order.

src/type.cadd 6 lines at the bottom of the file
1
void type_check_program(const NodeList *program, const char *src) {
2
    binding_count = 0;

Compiling a program is a fresh start. Without this line, a second call in the same process, which is exactly what a test binary running several cases does, would see names left behind by the previous call and either resolve against the wrong binding or silently pass a lookup that should have failed. One assignment, and every call behaves like the first one.

3
    for (int i = 0; i < program->count; i++) {
4
        check_node(program->items[i], src);
5
    }
6
}

src/type.h · add the declaration, above the closing #endif

src/type.hadd 2 lines, needs a forward reference to NodeList
1
typedef struct NodeList NodeList;

A forward declaration: enough for a pointer to NodeList to appear in a function signature, without type.h having to #include "ast.h" and reverse the one-directional dependency step 4 set up. type.c itself already includes ast.h directly, where the real definition is needed.

2
void type_check_program(const NodeList *program, const char *src);

Checkpoint

$ make test warning: enumeration values not handled in switch: 'NODE_UNARY', 'NODE_BINARY', 'NODE_LET'...

Plain make test, not -Werror, same reason as chapter 4 step 3: the warning is correct and expected, and it goes away over the next two steps as the rest of the cases get filled in.

NODE_UNARY only ever means - at this point in the language, and NODE_BINARY only ever means + - * /. Both rules say the same thing: every side has to be an Int, or the checker names what it actually found and hands back TYPE_ERROR.

src/type.c · inside check_node's switch, where the comment placeholder is

src/type.cadd 24 lines
1
    case NODE_UNARY: {

Braces around a case body that declares its own local variables. Without them, operand here would be visible to every case below it in the same switch, which is legal C and a source of exactly the kind of bug that looks fine until it does not.

2
        TypeKind operand = check_node(node->as.unary.operand, src);
3
        if (operand == TYPE_ERROR) {

The propagation guard. Checked first, before anything else about this node. Step 9 is entirely about why.

4
            result = TYPE_ERROR;
5
        } else if (operand == TYPE_INT) {
6
            result = TYPE_INT;
7
        } else {
8
            diag_error(src, node->offset,

Reported against node->offset, the minus sign's own position, which is why chapter 5 made every node record where it came from in the first place. Without it this error would have nowhere honest to point.

9
                       "'-' needs Int, found %s", type_name(operand));
10
            result = TYPE_ERROR;
11
        }
12
        break;
13
    }
14
    case NODE_BINARY: {
15
        TypeKind left = check_node(node->as.binary.left, src);
16
        TypeKind right = check_node(node->as.binary.right, src);

Both sides get checked unconditionally, even if left already came back TYPE_ERROR. Skipping right once left fails would also skip reporting a real, separate mistake sitting on the right side, and short-circuiting error reporting is not the kind of short-circuiting this pass is for.

17
        if (left == TYPE_ERROR || right == TYPE_ERROR) {
18
            result = TYPE_ERROR;
19
        } else if (left == TYPE_INT && right == TYPE_INT) {
20
            result = TYPE_INT;
21
        } else {
22
            diag_error(src, node->offset,

Names both sides, even though usually only one of them is the culprit. Saying "found Int and Bool" costs nothing extra to write and saves the reader a second look back at the line to work out which side was the problem.

23
                       "arithmetic needs Int on both sides, found %s and %s",
24
                       type_name(left), type_name(right));
25
            result = TYPE_ERROR;
26
        }
27
        break;
28
    }

This is the rule that refuses this chapter's opening example. x + y checks x, gets TYPE_INT back from the table, checks y, gets TYPE_BOOL, and neither side is TYPE_ERROR so the guard on line 17 does not fire. Left is Int, right is not, so line 19's condition fails too, and the program is refused with arithmetic needs Int on both sides, found Int and Bool, pointing at the +.

Checkpoint

$ make test warning: enumeration values not handled in switch: 'NODE_LET', 'NODE_VAR'...

Five cases down, five to go. Still plain make test for one more step.

The five statement cases left. None of them introduce a new idea. Every one of them checks its inner expression, and every one of them produces TYPE_VOID, because a statement is not a value.

src/type.c · inside check_node's switch, filling out the remaining cases

src/type.cadd 24 lines
1
    case NODE_LET:

Falling through into NODE_VAR below on purpose. Whether the binding is mutable is a fact the evaluator cares about, stored in chapter 5's Binding. It is not a fact the type of the initializer depends on, so one case handles both.

2
    case NODE_VAR: {
3
        TypeKind value_type = check_node(node->as.binding.value, src);
4
        record_type(node->as.binding.name, node->as.binding.length, value_type);
5
        result = TYPE_VOID;
6
        break;
7
    }
8
    case NODE_ASSIGN: {

Whether the target is even allowed to be reassigned, the immutability rule locked in chapter 5, is checked elsewhere, against the mutable flag on the evaluator's Binding. This case only asks a type question: does the new value match what the name already is.

9
        TypeKind existing = lookup_type(node->as.binding.name, node->as.binding.length,
10
                                         src, node->offset);
11
        TypeKind value = check_node(node->as.binding.value, src);
12
        if (existing != TYPE_ERROR && value != TYPE_ERROR && existing != value) {
13
            diag_error(src, node->offset,
14
                       "'%.*s' is %s, cannot assign %s",
15
                       node->as.binding.length, node->as.binding.name,
16
                       type_name(existing), type_name(value));
17
        }
18
        result = TYPE_VOID;
19
        break;
20
    }
21
    case NODE_PRINT:

The return value of check_node is thrown away here on purpose. print accepts any type, an Int or a Bool both print fine, so there is nothing to compare its result against. The call still has to happen, because the expression underneath, x + y inside print x + y;, is exactly where the interesting error lives, and skipping the call would skip checking it.

22
        check_node(node->as.stmt.value, src);
23
        result = TYPE_VOID;
24
        break;
25
    case NODE_EXPR_STMT:
26
        check_node(node->as.stmt.value, src);
27
        result = TYPE_VOID;
28
        break;
29
    }
30
    node->vtype = result;
31
    return result;
32
}

Now step back and look at the guard that opens both cases in step 8: if (operand == TYPE_ERROR), if (left == TYPE_ERROR || right == TYPE_ERROR). That single line in each case is the entire propagation policy from step 2 made real. Take let x = true + 1; print x;. Checking true + 1 reports one message, "arithmetic needs Int on both sides", and hands back TYPE_ERROR. record_type stores x as TYPE_ERROR. When print x; is checked, NODE_IDENT looks x up and gets TYPE_ERROR back, but nothing in the NODE_PRINT case compares that to anything, so no second message fires. One real mistake, one message. If a later chapter added, say, a rule that print refuses Void, that rule would need the same guard at its own top, checking for TYPE_ERROR before comparing anything else. The rule is not "the addition happens to check for it". The rule is "every rule checks for it first, always, by hand, because C has no feature that does it for you."

Checkpoint

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

Ten cases, ten NodeType values, clean under -Werror. The checker is complete for everything the language can currently parse.

let x: Int = 10; parses, and the annotation is checked against what 10 already infers to. It never gets to decide that answer.

src/lexer.h · inside the typedef enum, next to TOK_EQUAL

src/lexer.hadd 1 line
1
    TOK_EQUAL,
2
    TOK_COLON,

Punctuation, so it sits with the other single-character tokens rather than near the keywords.

src/lexer.c · token_type_name, next to the TOK_EQUAL case · the switch(c) at the bottom of lexer_next, next to the '=' case

src/lexer.cadd 4 lines
1
    case TOK_COLON:
2
        return "COLON";
src/lexer.c · the switch(c)add 1 line
1
        case '=': return make_token(lx, TOK_EQUAL, start);
2
        case ':': return make_token(lx, TOK_COLON, start);

No lookahead needed. Nothing else in the language starts with a colon, so a single character is always the whole token, unlike the = versus == question chapter 4 flagged and deliberately left alone.

Next, teach type.c to read a type name off the source text. This belongs here, not in the parser, because recognizing what counts as a type is the same question type_name already answers in reverse.

src/type.h · add the declaration · src/type.c · add the function, below type_name

src/type.hadd 1 line
1
int type_from_name(const char *name, int length, TypeKind *out);
src/type.cadd 10 lines
1
int type_from_name(const char *name, int length, TypeKind *out) {

Returns 1 and writes through *out on a match, 0 and leaves *out alone otherwise. A plain int return rather than bool, matching the style parser_match already set for a yes/no answer that also has to hand something back.

2
    if (length == 3 && memcmp(name, "Int", 3) == 0) {
3
        *out = TYPE_INT;
4
        return 1;
5
    }
6
    if (length == 4 && memcmp(name, "Bool", 4) == 0) {
7
        *out = TYPE_BOOL;
8
        return 1;
9
    }
10
    return 0;
11
}

Int and Bool are not keywords. They never went into lexer.h. The lexer hands them back as an ordinary TOK_IDENT, the same token kind a variable name gets, and it is only here, where the parser specifically expects a type, that the text gets asked whether it names one. That keeps the lexer from needing to know that types exist at all.

src/parser.c · in the function that parses a let or var statement, directly after it consumes the name, before it requires TOK_EQUAL

src/parser.cadd 15 lines
1
    bool has_annotation = false;
2
    TypeKind annotation = TYPE_VOID;
3
    if (parser_match(p, TOK_COLON)) {

The whole annotation is optional, so the first move is checking whether it is even there. If it is not, both locals above keep their defaults and every downstream line reads that as "no annotation given".

4
        if (!parser_check(p, TOK_IDENT)) {
5
            error_at(p, "expected a type name");
6
            return NULL;
7
        }
8
        const char *type_start = p->current.start;
9
        int type_length = p->current.length;
10
        parser_advance(p);
11
        if (!type_from_name(type_start, type_length, &annotation)) {

The identifier is already consumed by the time this check runs, which is why the error below has to rebuild its own offset from type_start rather than reading p->current, which has already moved past it.

12
            diag_error(p->lexer.src, type_start - p->lexer.src,
13
                       "unknown type '%.*s'", type_length, type_start);
14
            return NULL;
15
        }
16
        has_annotation = true;
17
    }

src/ast.h · the binding member of the union, and the ast_binding declaration

Chapter 5 gave the binding member three fields and the constructor four parameters:

src/ast.h2 edits
1
        struct { const char *name; int length; Node *value;

Add the two new fields to the end of this member, so the existing three keep their names.

2
                 bool has_annotation; TypeKind annotation; } binding;

You need #include "type.h" in ast.h for TypeKind, and <stdbool.h> for bool. Watch the include direction: type.h must not include ast.h back, or you get a cycle the include guards turn into a confusing missing-type error rather than an obvious loop.

3
Node *ast_binding(NodeType type, const char *name, int length, Node *value,

Two more parameters on the constructor, and two more lines in its body in ast.c storing them.

4
                  bool has_annotation, TypeKind annotation);

There are three call sites in parse_statement: the shared let and var branch, and the assignment branch. Pass false, TYPE_VOID from the assignment one, because x = 5; has no place to write a type, and pass the two variables you just parsed from the let and var branch.

src/type.c · back in check_node's NODE_LET / NODE_VAR case, before record_type

src/type.cadd 8 lines
1
        TypeKind value_type = check_node(node->as.binding.value, src);
2
        if (node->as.binding.has_annotation && value_type != TYPE_ERROR &&

Skips the comparison entirely when value_type is already TYPE_ERROR. Same propagation guard as step 9: the initializer already produced its one message, so the annotation check does not need to pile on a second one about a value that was already wrong for an unrelated reason.

3
            node->as.binding.annotation != value_type) {
4
            diag_error(src, node->offset,
5
                       "'%.*s' is declared %s but initialized with %s",
6
                       node->as.binding.length, node->as.binding.name,
7
                       type_name(node->as.binding.annotation), type_name(value_type));
8
            value_type = TYPE_ERROR;
9
        }
10
        record_type(node->as.binding.name, node->as.binding.length, value_type);
Why the annotation is never allowed to win the argument

Look at the order in that block. value_type is computed first, entirely by recursing into 10 or whatever the initializer is, with no knowledge that an annotation even exists. Only after that answer is final does the annotation get compared to it. Flip that order, and let the annotation set x's type directly instead of checking it, and you have quietly built a second, contradictory source of truth: let x: Bool = 10; would make x a Bool holding an Int, and every later use of x would trust a promise the initializer never kept. Checking a written annotation costs one comparison. Trusting it costs correctness, for the entire rest of the program, in exchange for looking like it caught the same mistake.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

One call in the driver, then a test file that starts with the example from step 1.

src/main.c · in main(), between where the program finishes parsing and where the first statement gets evaluated

src/main.cadd 1 include, roughly 4 lines
1
#include "type.h"
 
2
    type_check_program(program, src);

Your chapter 5 driver names its parsed result something; substitute that name for program here. If any statement's checking reported an error, diag_error has already printed it to stderr by the time this call returns.

3
    if (had_type_error) {

Exactly the same fail-fast shape main() already uses when parser_parse returns NULL: stop, free what was allocated, return a non-zero status, and never reach the evaluator. Whether "did an error happen" comes back as a return value from type_check_program or a flag it sets is your call to make when you write it; the shape of the check afterward does not change either way.

4
        return 1;
5
    }

This is the line that turns everything above from a function nobody calls into a real gate. Before it, check_node was correct and untested by the compiler itself. After it, no program with a type error ever reaches eval.

tests/test_type_1.c · new file, copy the shape of tests/test_parser_1.c

Assertions to write, each one exercising a rule from a specific step above.

assertions to write7 cases
1
type_name(TYPE_INT) ... TYPE_ERROR

All four strings, in order. Cheapest possible test and it still would have caught a typo in step 3.

2
"1 + 2"

Parse it, run type_check_program, assert the tree's top-level expression statement type checks with no error path taken. The happy case, so a later assertion failing tells you something actually broke instead of nothing having worked in the first place.

3
"true"

One expression, type TYPE_BOOL. Proves step 4 and step 7's leaf case both work without any arithmetic involved.

4
"let x = 10; let y = true; print x + y;"

The one from step 1. This is the assertion the whole chapter is for. Check that the program is refused, not that it silently keeps going.

5
"let x: Int = 10;"  then  "let x: Bool = 10;"

One accepted, one refused, proving the annotation check runs in both directions.

6
"let x = true + 1; print x;"

Redirect stderr to a buffer and count the lines that start with error:. Exactly one. This is the propagation test from step 9, and it is the one most likely to quietly break if a later change adds a check that forgets the TYPE_ERROR guard.

7
"print z;"

Refused with 'z' is not declared. Proves lookup_type's failure path from step 7, not just its success path.

tests/golden/ · new fixtures, following the existing .iz / .expected pairs

The unit test above checks that an error happens. The golden tests pin down exactly what it says, the same discipline chapter 3's diagnostics already earned: a compiler error is a user interface, and changing its wording should show up as a diff someone has to approve, not a side effect nobody notices. Add at least tests/golden/type-mismatch.iz for the arithmetic case and tests/golden/annotation-mismatch.iz for the annotation case, each with a matching .expected file holding the exact stderr output, in the same rustc-style layout diag_error already produces, right down to the caret's column.

Checkpoint

$ make EXTRA_CFLAGS=-Werror test golden: 11 cases match all unit tests passed

Every earlier golden case still matches, your two new ones pass, and let x = 10; let y = true; print x + y; is now a program izvor refuses to run, with a message that says exactly why.