05 — Function Types and Call Checking
Function types form the most complex part of the type system: they are recursive (parameters and return types can themselves be function types), and call-site checking must verify arity and argument types.
Checking a call expression
TypePtr TypeChecker::visitCall(CallExpr& call) {
TypePtr calleeType = check(*call.callee);
// If the callee is Any, we can't check — return Any
if (std::holds_alternative<AnyType>(*calleeType)) {
for (auto& arg : call.args) check(*arg); // still check arg types
return mkAny();
}
// Must be a function type
auto* fn = std::get_if<FnType>(calleeType.get());
if (!fn)
throw TypeCheckError("[line " + std::to_string(call.line) +
"] Cannot call non-function value of type " +
typeToStr(*calleeType) + ".");
// Check arity
if (call.args.size() != fn->params.size())
throw TypeCheckError("[line " + std::to_string(call.line) +
"] Expected " + std::to_string(fn->params.size()) +
" arguments, got " + std::to_string(call.args.size()) + ".");
// Check argument types
for (size_t i = 0; i < call.args.size(); ++i) {
TypePtr argType = check(*call.args[i]);
checkCompatible(fn->params[i], argType, call.line);
}
return fn->ret;
}
Higher-order functions
Function types compose naturally:
fn apply(f: Fn(Num)->Num, x: Num): Num {
return f(x);
}
fn double(n: Num): Num { return n * 2; }
print apply(double, 21); // 42
Type checking apply(double, 21):
applyhas typeFn(Fn(Num)->Num, Num) -> Num.- Arg 0:
doublehas typeFn(Num)->Num. Compatible with param 0 (Fn(Num)->Num). ✓ - Arg 1:
21has typeNum. Compatible with param 1 (Num). ✓ - Return type:
Num.
Recursive functions
fn fib(n: Num): Num {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}
When visitFn resolves the body, fib is already declared in the enclosing
scope with type Fn(Num)->Num (set when the let fib = fn(n:Num):Num {...}
desugaring runs visitLet). So the recursive call fib(n-1) finds the
correct function type in the scope.
The key ordering: visitLet calls declare after type-checking the
initialiser for forward-referenced functions? No — declare must happen
before checking the body for recursion to work. Here's the fix:
void TypeChecker::visitLet(LetStmt& s) {
// For function literals, pre-declare with the annotated type
// before checking the body (enables recursion).
if (auto* fn = dynamic_cast<FnExpr*>(s.init.get())) {
if (fn->retAnnotation) {
auto preType = buildFnType(*fn); // params + ret from annotations
declare(s.name, preType); // pre-declare
check(*s.init); // body can now see s.name
return;
}
}
// Non-function or unannotated: infer normally
TypePtr initType = s.init ? check(*s.init) : mkNil();
if (s.annotation) checkCompatible(s.annotation, initType, s.line);
declare(s.name, s.annotation ? s.annotation : initType);
}
The interaction with Any
let f: Any = fn(x: Num): Num { return x + 1; };
f(42); // accepted — callee is Any, no type checking on call
f("hi"); // accepted — but will crash at runtime
The Any escape hatch means the checker accepts the call (returns Any)
while the interpreter's runtime check catches the actual type mismatch.
This is the defining property of gradual typing: you can opt specific
sites out of static checking at the cost of runtime safety guarantees.
Testing function types
void test_function_types() {
// Correct types — should type-check clean
CHECK_NOTHROW(typeCheck(R"(
fn add(a: Num, b: Num): Num { return a + b; }
print add(1, 2);
)"));
// Wrong arg type
CHECK_THROWS(typeCheck("fn f(x: Num): Num { return x; } f(\"hi\");"),
"Expected Num");
// Wrong arity
CHECK_THROWS(typeCheck("fn f(x: Num): Num { return x; } f(1, 2);"),
"Expected 1 arguments");
}