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

  1. Unannotated parameters: fn f(x) { ... }x has type Any.
  2. Unannotated return: fn f() { return expr; } → return type is Any.
  3. Unannotated variable with complex initialiser: if the checker can't infer a concrete type, it falls back to Any.
  4. 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");
}