Type checking works out the type of every expression, and reports each place where a value can't be what it's used as: a String passed where a U64 is wanted, a field a struct doesn't have, numbers of two types added together. It's the step that catches most mistakes before the program runs.
Given every file's syntax tree and links. Gives the type of every expression, every name bound in a function, every function and every field - and what doesn't fit. In bootstrapping/4-typecheck; --typecheck prints each expression's type.
The checker is gradual: whatever it can't work out is Unknown, and Unknown fits anywhere, and anything fits it. So it only reports what it's sure of. A value from a macro that hasn't run, a parameter written without a type, a lambda whose block body gives something - each is Unknown, and the code around it is checked as far as it can be. The program still compiles, and the running program checks the values themselves as they meet.
That also means type checking never has to be complete to be useful: a check that isn't written yet is just a mistake it doesn't catch. What it doesn't check yet is listed at the end of this page.
The builtin types, their methods and functions, the interfaces, the context values and their types are what docs/src/core.md says - the compiler doesn't list them itself. The bootstrapping compiler carries it as core.rfn, Refingere data generated from core.md: each type with how many parameters it takes, each alias (String is UTF8), each method's signature as core.md writes it. A signature is only parsed, by the compiler's own parser, the first time it's looked up. A member is found on the type's own block first, then on the blocks it has everything on - Array has everything on Iterable - then on the interfaces it implements, then on what every value has.
A struct's, enum's, union's or alias's type comes from its definition, found through analysis's links - in whichever file it's in, with names looked up there. A function's type is its signature; a const's is its annotation or, failing that, its value's. Each is worked out once, the first time it's needed, and kept. A method is found by the type it's written for, whether with method name for Type or inside an implement block.
A context value's type is, in order: the reading function's own @require; the context name: Type declaration analysis linked the read to - the file's local one, the folder's, or a global one; an @inject, @injectLocally or @injectGlobally const of that name reaching it, by the const's type; and the builtin's, from core.md. An inject is checked against the same type.
Each function's context reads are noted as its body is checked - with what's injected at each read and call - and once every file is checked, a function's needs reach the functions that call it, until nothing changes. A function that ends up needing one it doesn't @require - or name in an effect attribute, @reads(&clock) - is warned about, with the @require to write: the type is written the way the function's file can name it - by its name, a local type alias of it, or its path.
Then what each function can do is worked out (effects.rfn), from the checked bodies and the types recorded in them: the context values it reads, what it changes through a parameter or a context value - following each local back to where its value came from - and the I/O done by the builtin members it calls, which core.md marks (@writes(self) method push, @effects(self) method writeText). A call takes on its callee's effects, the callee's parameters standing for what the call gives them, until nothing changes. Each @pure, @reads, @writes, @effects or @memoize written on a function - or on the interface member it implements - is checked against that, naming what breaks it and the call it comes through.
Every definition is checked in turn. A function's parameters are bound to their types, then its body is walked statement by statement, and each expression's type is worked out from its parts - a name's from where it's bound, a call's from its callee's result, a member's from the type it's on. What an expression is expected to be goes down into it where it helps: a literal settles on the type it's wanted as, and a lambda passed to map gets its parameter's type from the list.
A number literal has no fixed type until it's used. 0 fits any number type, 2.5 any float type, and a string literal any text type. Where it meets a typed value it takes that type - if it can.
A call to a generic function binds its type parameters from the arguments it's given, in order - each argument's type is matched against its parameter's, and what's found is used for the rest of the call and for its result.
Whether a value of one type fits where another is wanted: the same type does; a literal fits what it could be; a union fits if each of its members does, and fits into a union if it fits one member; a struct fits an enum that's combined from it (enum Shape ≔ Circle || Square); an error fits Error; what implements an interface fits it - an implement block for it, a @derive, or what core.md says a builtin implements (an Array<U8> is an Iterable<U8>, which is how a generic interface's arguments are found). A literal joined with a typed value settles on it: if n < 2 then 1 else n is the U64n is.
A condition says something about the values it tests: x ≠ undefined, x is T, x.isDefined(), and those joined by and - or, when it doesn't hold, by or. Where it holds, and where it doesn't, the value has the narrower type, as it does after an if whose branch always leaves (if x = undefined then { return }). A narrowed value is a local or a field chain off one (self.cache). With that, a value that might be undefined or an error can't be used as though it were neither: a member, a call, an index, arithmetic and an ordering need it handled first - with ?., ??, !!, a check or a match.
Each line is an expression's position, its text, and its type. Later steps use these: lowering reads the type of each name and literal, and whether a name is a type or a value.
let, a returned value, a const, a default or a set of the wrong type; setting a constand/or/not on a non-Boolean, ++ of a String with a numberfor loop over something that can't be iteratederror or !! in a function that can't fail, !! on a value that can't be an error, !! in a lambdanew - Regex("a+") is Regex.new("a+"), checked as thatT: Comparable)Comparable@require it@pure, @reads, @writes, @effects or @memoize allows - itself or through what it calls; @slow with @inline A match needn't cover every case: one none of whose cases match runs none. Whether one does is still worked out - a match whose cases all leave, and take every value between them, leaves too, for narrowing.
src/4-typechecker reads core.md directly when it starts, and checks the same things the same way. It leaves each expression's type on its node, for the editor's hover - and each function's effects (effects.ts), which the hover shows too.
Next: Lowering.