07 — Error Messages and Recovery
A type system is only useful if its error messages are actionable. This step covers how to produce clear diagnostics and how to continue checking after the first error.
Anatomy of a good type error
error[E001]: type mismatch
--> main.ml:3:15
|
3 | let x: Num = "hello";
| ^^^^^^^^^^ expected Num, found Str
|
= note: variable 'x' is declared as Num at line 3
The key components:
- Error code — makes errors searchable in documentation.
- Location — file, line, column (or at least line).
- Expected vs actual — always say what was expected, not just what was found.
- Context — which variable, which function, which call.
cp-05's error format is simpler but includes the essentials:
TypeCheckError at line 3: expected Num, got Str (in binding 'x')
The TypeCheckError structure
struct TypeCheckError {
std::string message;
int line;
TypeCheckError(std::string msg, int line)
: message(std::move(msg)), line(line) {}
const char* what() const { return message.c_str(); }
};
Error messages are assembled at the site of detection:
void TypeChecker::checkCompatible(TypePtr expected, TypePtr actual,
int line, const std::string& context) {
if (!compatible(*expected, *actual))
throw TypeCheckError(
"expected " + typeToStr(*expected) +
", got " + typeToStr(*actual) +
(context.empty() ? "" : " (" + context + ")"),
line);
}
Error recovery: collect-and-continue
Throwing on the first error is the simplest strategy. The downside is that one typo hides all downstream errors. The collect-and-continue strategy accumulates errors:
class TypeChecker {
std::vector<TypeCheckError> errors_;
void reportError(const std::string& msg, int line) {
errors_.emplace_back(msg, line);
}
TypePtr checkCompatibleSoft(TypePtr expected, TypePtr actual, int line,
const std::string& ctx) {
if (!compatible(*expected, *actual))
reportError("expected " + typeToStr(*expected) +
", got " + typeToStr(*actual) + " " + ctx, line);
return expected; // return expected type to continue checking
}
};
After reportError, the checker returns the expected type and continues.
Downstream code sees a "correct" type and may or may not produce spurious
secondary errors. Primary errors (the first one) are always reliable;
secondary errors may be false positives caused by recovery.
The "error type" sentinel
Some type checkers introduce an ErrorType variant. Any operation on an
ErrorType operand silently returns ErrorType without reporting another
error. This prevents cascading:
let x: Num = "str"; // error: expected Num, got Str → x gets ErrorType
let y = x + 1; // x is ErrorType → suppress the type error for +
Without ErrorType, the second line would also error "expected Num, got
ErrorType-derived-Str", confusing the user.
The complete pipeline check
void runProgram(const std::string& src) {
Lexer lex(src);
auto tokens = lex.scanAll();
Parser parser(tokens);
auto stmts = parser.parse();
Resolver resolver(interp);
resolver.resolve(stmts);
TypeChecker checker;
checker.check(stmts);
if (!checker.errors().empty()) {
for (auto& e : checker.errors())
std::cerr << "[line " << e.line << "] " << e.message << "\n";
return;
}
for (auto& s : stmts) interp.execute(*s);
}
Testing error messages
The test suite verifies not just that errors are reported but that the message contains the right content:
void test_error_messages() {
try {
typeCheck("let x: Num = true;");
std::cerr << "FAIL: expected error\n";
} catch (const TypeCheckError& e) {
CHECK_CONTAINS(e.message, "Num");
CHECK_CONTAINS(e.message, "Bool");
}
try {
typeCheck("fn f(x: Num) {} f(\"hi\");");
std::cerr << "FAIL: expected error\n";
} catch (const TypeCheckError& e) {
CHECK_CONTAINS(e.message, "Num");
CHECK_CONTAINS(e.message, "Str");
}
}
What you've built by the end of cp-05
- A type ADT (
Num,Bool,Str,Nil,Any,Fn(...)→...). - Optional type annotations in the parser without breaking old code.
- A type-checker pass that infers and verifies types for all expressions.
- Gradual typing via
Anyfor incremental annotation of large codebases. - Clear error messages with expected-vs-actual and source locations.
- A complete pipeline: parse → resolve → type-check → interpret.
This is the foundation that every production language's front-end is built on. The gap between cp-05 and, say, TypeScript's checker is not concept but scale: more types, more inference, more generics — but the same propagate-and-unify core.