06 — Gradual Typing with Any
Gradual typing lets programmers mix typed and untyped code in the same
program. The Any type is the mechanism: a value of type Any bypasses
static checks but retains runtime checks.
The key rule: compatibility not equality
The type checker uses compatible(A, B) instead of typeEq(A, B) for
all mismatch checks:
bool compatible(const Type& a, const Type& b) {
if (std::holds_alternative<AnyType>(a)) return true;
if (std::holds_alternative<AnyType>(b)) return true;
if (a.index() != b.index()) return false;
if (auto* fa = std::get_if<FnType>(&a)) {
auto& fb = std::get<FnType>(b);
if (fa->params.size() != fb.params.size()) return false;
for (size_t i = 0; i < fa->params.size(); ++i)
if (!compatible(*fa->params[i], *fb.params[i])) return false;
return compatible(*fa->ret, *fb.ret);
}
return true; // same non-Fn variant
}
Any is compatible with every type in both directions. This is what
allows unannotated code to coexist with annotated code.
Where Any flows
- Unannotated parameters:
fn f(x) { ... }→xhas typeAny. - Unannotated return:
fn f() { return expr; }→ return type isAny. - Unannotated variable with complex initialiser: if the checker can't
infer a concrete type, it falls back to
Any. - Explicit annotation:
let x: Any = ...always.
The flow problem
Gradual typing introduces a subtlety: Any can flow through operations
and infect computed types:
let x = f(); // f is unannotated → x has type Any
let y: Num = x + 1; // x is Any, so x+1 is Any, compatible with Num → accepted
At runtime, if f() returns a string, x + 1 crashes. This is the
fundamental trade-off: accepting Any means losing static guarantees
for all downstream computations that use that value.
Gradual guarantee
The gradual guarantee says: if you annotate everything (no Any), the
program passes static type checking iff it is type-safe at runtime. As
soon as you introduce Any, you give up some of that guarantee for the
paths touched by Any values.
In practice this means: start with Any everywhere (fully dynamic), add
annotations incrementally, and the type checker's coverage grows with
each annotation.
Runtime blame
In research gradual type systems (Typed Racket, Reticulated Python), the
runtime inserts casts at Any ↔ typed boundaries and reports blame
precisely: "this function was typed but received an untyped argument from
line 42". cp-05 does not implement blame tracking — the runtime check is
just the existing std::get<double> in the interpreter throwing a
RuntimeError. The blame extension is left for exploration.
Testing gradual typing
void test_gradual() {
// Unannotated fn — no type error
CHECK_NOTHROW(typeCheck("fn f(x) { return x + 1; }"));
// Annotated caller, unannotated callee — OK (Any is compatible with Num)
CHECK_NOTHROW(typeCheck(R"(
fn id(x) { return x; }
let n: Num = id(42);
)"));
// Annotated callee, wrong caller argument — error even with Any param
// If callee is fn(x: Num), passing a Str is an error
CHECK_THROWS(typeCheck(R"(
fn f(x: Num) { return x + 1; }
f("hello");
)"), "Expected Num");
}