02 — The Type ADT
The type representation is the data model for the entire type checker. It must be:
- Recursive: function types contain other types.
- Comparable:
unifyand equality checks must work. - Printable: error messages need
"expected Num, got Bool".
The Type representation
struct NumType {};
struct BoolType {};
struct StrType {};
struct NilType {};
struct AnyType {};
struct FnType {
std::vector<std::shared_ptr<Type>> params;
std::shared_ptr<Type> ret;
};
using Type = std::variant<NumType, BoolType, StrType, NilType, AnyType, FnType>;
using TypePtr = std::shared_ptr<Type>;
Alternatively, use an inheritance hierarchy with a TypeKind enum. The
variant approach avoids virtual dispatch and is exhaustively checkable
with std::visit:
std::string typeToStr(const Type& t) {
return std::visit(overloaded{
[](const NumType&) { return std::string("Num"); },
[](const BoolType&) { return std::string("Bool"); },
[](const StrType&) { return std::string("Str"); },
[](const NilType&) { return std::string("Nil"); },
[](const AnyType&) { return std::string("Any"); },
[](const FnType& f) {
std::string s = "Fn(";
for (size_t i=0; i<f.params.size(); ++i) {
if (i) s += ", ";
s += typeToStr(*f.params[i]);
}
s += ") -> " + typeToStr(*f.ret);
return s;
},
}, t);
}
Type equality
Two types are equal if they are structurally identical:
bool typeEq(const Type& a, const Type& b) {
if (a.index() != b.index()) return false;
if (auto* f = std::get_if<FnType>(&a)) {
auto& g = std::get<FnType>(b);
if (f->params.size() != g.params.size()) return false;
for (size_t i = 0; i < f->params.size(); ++i)
if (!typeEq(*f->params[i], *g.params[i])) return false;
return typeEq(*f->ret, *g.ret);
}
return true; // same variant index, non-FnType
}
The "gradual" compatibility check
For gradual typing (step 06), we need compatible(a, b) which is weaker
than typeEq: Any is compatible with everything.
bool compatible(const Type& a, const Type& b) {
if (std::holds_alternative<AnyType>(a)) return true;
if (std::holds_alternative<AnyType>(b)) return true;
return typeEq(a, b);
}
Type factory helpers
TypePtr mkNum() { return std::make_shared<Type>(NumType{}); }
TypePtr mkBool() { return std::make_shared<Type>(BoolType{}); }
TypePtr mkStr() { return std::make_shared<Type>(StrType{}); }
TypePtr mkNil() { return std::make_shared<Type>(NilType{}); }
TypePtr mkAny() { return std::make_shared<Type>(AnyType{}); }
TypePtr mkFn(std::vector<TypePtr> params, TypePtr ret) {
return std::make_shared<Type>(FnType{std::move(params), std::move(ret)});
}
These reduce noise in the checker: mkNum() is clearer than
std::make_shared<Type>(NumType{}) at every call site.
Why shared_ptr?
Function types contain nested types. A type like Fn(Fn(Num)->Bool, Str) -> Nil
has a FnType whose first param is another FnType. Shared ownership
means the inner types can be cheaply aliased across multiple places in the
checker's environment without copying.
For the curriculum, shared_ptr is clear and correct. Production type
checkers use arenas (bump allocators) so all types live in one allocation
region and are freed together at compile-time end.