Expand description
Static type checking, between resolution and execution.
ADR 0004 decides what this checks and how: annotations are mandatory at
boundaries and inferred inside, types are nominal with no subtyping, and
checking is per-module. A module sees its own declarations, whatever it
imports with use, and the builtins. ADR 0006 replaces that ADR’s
“parametric and unbounded” with “parametric with bounds”, which is the
change it anticipated.
§The import environment
ADR 0005 makes a module able to name another module’s exported
declarations, and ADR 0004 anticipated exactly one change for it: the
checker gains an import environment. That is Checker::import, and
nothing else about the pass is different.
One rule holds it together. A declaration is known by the module that
declares it: its table key is its bare name inside that module, and
module.Name everywhere else. So two modules may each declare a
Config without the checker confusing them, and a type keeps one
identity however many imports it is reached through. Traits and
conformances travel with it, so an imported trait can be named in a bound
and a conformance declared anywhere in the package is found wherever the
trait and the type are both in scope. Modules are checked in dependency
order, which exists because ADR 0005 forbids import cycles.
§Traits and the two dispatch forms
A bound (fn render<T: Display>(value: T)) is checked at the call site
that instantiates T, because that is the only place a type parameter is
given a type. Inside the body the parameter is rigid and its bound is a
fact: a method call on a value of type T resolves through T’s bounds,
and a parameter with no bound has no methods at all.
dyn Display is a type of its own, not a type parameter. Only the
trait’s plain self-taking methods can be called on it: an associated
function has no receiver to dispatch on, and a var self method needs the
caller’s own place, which a converted value is not. It never satisfies a
bound either — not even its own trait’s — because it is not a type
parameter.
§The one implicit conversion
A concrete value is accepted where a dyn Trait is expected, exactly when
it conforms to that trait. That is the language’s only implicit
conversion, and it is deliberately narrow:
- it runs one way only: a
dyn Traitvalue is never a concrete type, and never converts to anotherdyn Trait; - it never reaches inside a generic argument.
Array<Booking>is not anArray<dyn Display>, because generic arguments are invariant here like everywhere else.[booking, receipt]is anArray<dyn Display>, because each element is checked againstdyn Displayon its own; - it satisfies no bound.
render(someDyn)is an error even whenrender<T: Display>and the value is adyn Display.
It is spelled out here, in coerces, and nowhere else: every place that
compares a found type against an expected one goes through
Checker::expect or unify, and both consult it.
§The type representation
Ty is a closed enum of the builtin types, the structs and enums the
module declares, function types, rigid type parameters, dyn Trait, and
the Any a Host API schema declares. Two types are equal when they name
the same declaration and their arguments are equal: there is no subtyping
and no variance, so Array<Int> is not an Array<Any>.
Three variants are not types a program can write:
Ty::Unknownis the checker does not know, and carries anUnknownsaying why. It compares equal to every type whatever the reason, and every operation on it produces an unknown again, so one unknown never becomes a cascade of wrong errors.Ty::Neveris the type of an expression that does not produce a value, such asreturn. It also compares equal to every type, because an arm that never produces a value never disagrees with one that does.Ty::Anyis what a Host API schema declares where nothing it does depends on a type. It compares equal to every type and every operation on it abstains, which is what the schema promised; what it is not is aTy::Unknown, because a promise and an absence are different facts and a reader ofFactshas to be able to tell them apart. The two share one erased representation in the backends, which iscove_ir’sShapesto decide and not this pass’s.
§The four kinds of unknown
Unknown is one variant doing four jobs, and telling them apart is what
makes a successful cove check worth reading. The kind is carried by the
type, not implied by which constructor built it, so a form can ask what
the silence around it is made of — and so one kind can be asserted never
to escape:
| kind | constructor | cove check |
|---|---|---|
Unknown::Recovery | Ty::recovery | silent |
Unknown::DynamicBoundary | Ty::dynamic_boundary | silent here; see below |
Unknown::Unconstrained | Ty::unconstrained | error |
Unknown::Placeholder | Ty::placeholder | must not escape |
| language gap | none | warning or error |
Recovery is an unknown the checker owes no further word about, because everything there was to say was said — here, a few lines above the constructor, or upstream where the unknown being propagated came from. Every “the receiver is already unknown, so abstain” branch is one of these, and none of them adds a diagnostic. This is the job that keeps one mistake from printing as ten. Its reach goes one step further than the branch that builds it: the arguments of a rejected call are walked against a recovery expectation, so an empty array or an unannotated lambda parameter written inside one is not reported as a second mistake.
A dynamic boundary is a host no Host API schema describes. ADR 0001’s
Host API schema is cove_schema, which this crate reads:
console.println, http.Request, and every other operation and type of
a shipped host module is checked against the same description the
boundary dispatches it through — and so is every operation and type of a
module an embedder describes, because ADR 0017 lets an embedding hand
its own cove_schema::ModuleSchema to Compiler::with_host_schema and
this pass reads the two the same way. What stays unknown is a module
neither table names: a host may register whatever it likes, and one it
never described is one no compiler could read.
Nothing is reported per call into one. The fact is about the use that
named the module and about the compilation that was not shown it: no edit
to sensors.read can fix it, the remedy is one thing to say however many
calls a program makes, and it is the same remedy for a call, a member
read, and a value passed in. cove::resolve::unchecked_host puts that
warning at the use, where it belongs. What this pass owes such a call
is the abstention itself, handed to the arguments as their expected type,
so that a callback registered with an unschema’d host — the shape an
embedding is written in — is not asked to state a type nothing on this
side could have stated. A type named through such a module still warns
(HOST_TYPE), as it did before this classification existed.
Checker::host_schema is the one place an embedder-supplied schema has
to reach, and reaching it is all it takes to turn any of that back into
ordinary checking.
An unconstrained unknown is a type nothing that has been read
states: a type parameter no argument, annotation, expected type or later
use settles. It is UNCONSTRAINED, and it is an error — an empty
array literal, a bare None, a struct’s parameter no field mentions, an
unannotated lambda parameter nothing expects a type of, a binding whose
uses say nothing, and Ok(1) in a place expecting no Result are all
the same fact and are all refused. crates/cove-sema/tests/settled.rs is
the invariant that says why: a type nothing settles has no layout, so a
program holding one is not a program a backend can run, and a cove check that reported no error about it would be reporting that the
program is ready.
A schema’s Any is not one of these. It used to be — the same value,
from a source that had said something exact — and it is Ty::Any now.
What the schema said still costs the rest of the program wherever it is a
result or a field, and those are noted (UNCONSTRAINED_RESULT,
UNCONSTRAINED_FIELD). A note rather than a warning, because the
schema chose this and no strictness setting can make the checker prove
what nobody stated.
One shape of unconstrained type gets a diagnostic of its own. A use that
describes a binding in terms of itself — v.push(v) — asks for
μX. Vector<X>: regular, finitely representable, and with no surface
syntax, because recursion here is nominal and this inference is
structural. That is RECURSIVE_TYPE, and it points at the use and
names the declaration that would write the type instead.
A placeholder is not a fourth kind of not-knowing; it is the marker
for a position no reachable program observes. Some are internal
positions the surrounding form settles before reading them, and some are
branches no reachable program takes at all. Checker::expr and
Checker::declare assert in debug builds that one never reaches a type
a program can observe, so the claim each site makes about itself is one
the test suite holds it to rather than a comment. Two sites used to break
it — a struct’s type parameter that no field mentions, and
Result.mapError’s expected callback result — and each let a program
check clean and then be wrong at run time.
A language gap is information the checker should have been given and was not. These are the ones that used to pass silently, and none of them does now:
- a name nothing in scope explains, capitalized or not, is an error
(
UNRESOLVED_NAME,UNKNOWN_NAME,UNKNOWN_TYPE). A capitalized one used to be assumed to come from a host and warn; a host reaches a module throughuselike everything else, so the assumption named no real way for the name to arrive and only let an unknown through to validate whatever was done with it; - a type or a module written where a value belongs is an error
(
NOT_A_VALUE).VectorinVector.of(1, 2)is understood as part of the call; a bareVector,console, orCounteris not a form with a type in this system, and never was. A host operation is not one of these: it is a value, and reading the schema gives it the function type it declares, solet log = console.printlnkeeps working and a call through the value is checked. The one exception is a variadic operation, which nofntype in this language can describe — the language’s own gap, said out loud as a note (VARIADIC_AS_VALUE) rather than hidden or refused; - an early
returnin a function value nothing expects is an error (LAMBDA_RETURN). Such a lambda takes its result from its body’s value, so areturnproduces one where the body’s value is not, and nothing written anywhere says what the two have to agree on. “Nothing expects it” is asked of the expected result type: an expectation this pass abstained about answers for it, and one whose own result is a placeholder does not; - an unannotated lambda parameter, an empty array literal, a bare
None, and a struct’s type parameter no field mentions, each in a place that expects nothing in particular, are refused (UNCONSTRAINED). Writing the type is always available, which is what eachhelpsays. “Expects nothing in particular” excludes a place this pass already abstained about — a schema’sAnyanswers for what is given to it — and a sibling or a branch that settles the type counts as saying it:[[], [1]]andif c { None } else { Some(1) }are proved, and are silent.
One thing is deliberately not an unknown: the value a scope binds is
Ty::Scope, a type the language gives no name to but this pass knows
exactly.
§What a scope asks of the function it is written in
Leaving a scope waits for every task nothing awaited, and a task whose
value is Err(error) returns that error from the enclosing function,
exactly as ? would — that is cove_runtime::task::wait_for_children,
and it is the whole reason this pass has anything to say about a scope.
So a scope constrains its function only where one of its children can
produce a Cove Err. A scope of Unit-answering children asks nothing,
and stays at home in a function that answers nothing. Checker::spawned,
Checker::handle_awaited and Checker::leaving_scope are the three
points of that: what was spawned, what the program settled itself, and
what is left for the scope to return. A child that raises — fuel, an
invariant, a Host-boundary failure — is not part of it: that travels as a
runtime fault rather than as the function’s value, so it depends on no
Cove return type.
§Inference variables
One of those unknowns carries a number. Unknown::Var is an
unconstrained unknown with an identity, and the identity is what lets the
uses of a local binding settle a type its initializer left open:
var log = Vector.of() // Vector<a>
log.push(text) // a = StringChecker::open_result mints one wherever a call’s result mentions a type
parameter that neither its arguments nor its call site settled. A let
or a var with no written type gives every variable in the type it takes
to that binding (Checker::attach). Every comparison of a found type
with an expected one is read for what it says about them
(Checker::constrain, reached from Checker::expect and
Checker::check_argument), so a method call, an argument position, an
assignment and a declared return type are one rule and not four. And the
end of the body writes the answers back (Checker::finish_inference).
Issue #240 decided this, and drew the boundaries around it:
- only a local binding holds one. A parameter, a return type, and everything else a declaration publishes state their types where they are written, and none of them reaches this;
- two uses that disagree are an error (
INFERENCE_CONFLICT) naming both, rather than the first one quietly winning; - a variable nothing settled is asked for the annotation
(
UNCONSTRAINED), in the same shape the empty array literal is: at the binding when one took it, and at the call that produced the value when none did; - the scope ends with the body. A variable never outlives the
declaration that minted it, so no use in one declaration settles a type
in another, and nothing downstream of the checker sees one: what
reaches
Factsis the settled type, or the plain unconstrained unknown that a variable nothing settled was all along.
It is one mechanism and not a rule per collection: Vector.of(),
Set.of(), Map.of(), a generic declaration of the package’s own, a
generic enum case, and every builtin method whose result mentions an
unsettled parameter all reach open_result. Two nearby gaps are
deliberately not on it, because both report where they are written
rather than carrying a type forward: an empty array literal and an
unannotated lambda parameter. Moving them onto this would move their
diagnostics as well, which is a decision about where those warnings
belong rather than one about inference.
What a constraint cannot say, it does not say. A use whose own type still
holds a variable settles nothing — v.push(v) asks for a Vector of
itself, and no annotation writes that either. TyVar::spoken_for is that
case, and it is RECURSIVE_TYPE: the type exists, it cannot be
written, and a declared type is what writes it instead. It used to be
carried silently, which made it the one way a check that reported nothing
could hand a backend a Vector<_>.
A variable given to a place this pass abstained about is settled by the
abstention rather than reported. A schema declaring Any said there is
nothing here that depends on a type, so Ok(()) written in such a
callback takes Any as its failure type; asking the program to state one
would be asking it to state what the place declared it need not.
TyVar::abstained is that.
§What a clean check guarantees
cove check reporting nothing at all means every type the package wrote
down was checked: every struct field, declared parameter, call to a
declared or imported function, and call into a Host API module some
schema describes — shipped or embedder-supplied — was checked against a
written or schema-declared type.
One silence is not covered by that, and it is named here rather than left
to be discovered: a host module no schema describes. Nothing about a call
into one is proved, and nothing is said about it here either, because the
fact belongs to the use that names the module, where
cove::resolve::unchecked_host warns about it once. So a package
reaching such a host does not have a clean check: it has one warning per
use, naming the module whose schema was never handed over. It is the
one exclusion crates/cove-sema/tests/settled.rs makes, for the same
reason: what is missing is a description this build was never given, no
edit to the call fixes it, and a program that reaches one cannot be
lowered.
There used to be a second: a type parameter of a builtin constructor that
nothing settles. Ok(1) in a place expecting no Result was a
Result<Int, _> with the _ carried rather than reported. What Ok(1)
alone means is settled now, and the answer is that it means nothing until
something says what the failure type is — Result is generic in both,
and a package writing Result<Int, ParseError> is why guessing Error
would be a guess. The constructor opens an inference variable like any
other call, a later use may settle it, and UNCONSTRAINED asks for the
annotation when none does.
A check whose only output is notes means the same, except at the places
the notes name: a shipped schema declared Any there, or a variadic host
operation was used as a value, and what the program does with the value
from that point on is the boundary’s to check.
A check with warnings means the package left the checker something to
infer that nothing written settles. cove check --deny-warnings is
exactly the request that it did not.
What none of these guarantee is anything the runtime keeps for itself: task safety of a host resource, and every rule listed under What the runtime keeps below.
Two things about a shipped host module are read here and not enforced by
the boundary, which is worth stating in one place. A host type’s fields
are typed from the schema — request.path is a String because the
schema says a Request has one — while the boundary checks a declared
type by name only, so what this checks is that the program built the
value the schema describes. And a host resource’s declared task-safety is
still the runtime’s alone: Ty::Host says nothing about crossing a task
boundary, so a resource declaring task_safe: false is refused where it
crosses and not before.
§Places, and what kind of analysis this pass is
This pass is not only a type checker. ADR 0021 settles what else it is:
it may decide any fact the source settles through the binding structure
it already walks, and mutability is one. let creates a read-only place
and var a mutable one, so which places a program may write is read off
the scope stack — Checker::place_mutability is the definition, and
the interpreter and cove_ir::lower had a reading of it each until it
moved here.
Four constructs are refused by it, in the words the interpreter refused
them in: an assignment to a read-only place, a var argument that is a
read-only place or is no place at all, and a mutating receiver that is
either. A fifth is the same kind of fact about a call’s shape rather than
about a place: labeled arguments appear in declaration order, and
LABEL_ORDER is what says so.
Two more are facts about a declaration rather than about a call, and
ADR 0021’s rule reaches them by the same test — the parameter list is
structure this pass already walks to build a signature. A variadic
parameter is the last one its declaration writes (VARIADIC_POSITION)
and is not written with a default (VARIADIC_DEFAULT).
Checker::check_variadic_shape is where both are decided. Unlike the
five above, these two are wording of this pass’s own, because there was
no behaviour to keep: Interpreter::assign_labels and bind_params
disagreed about what a non-last variadic parameter binds, and nothing in
either backend could ever reach a variadic parameter’s default.
A third is about where a variadic parameter may be written at all: on a
declaration, and not on a function value (VARIADIC_LAMBDA,
Checker::lambda). This one does not come from ADR 0021 but from
ADR 0016 — a function type names a fixed list of parameters, and a
function value has exactly the parameters its type names. It was the VM’s
lowering that refused it first, and the two backends disagreed underneath
that refusal: this pass typed such a parameter as its element type and
dropped the ..., while Interpreter::bind_params wrapped the argument
in an Array as it does for any variadic slot, so fn(items: Int...)
called with 1 bound 1 on one backend and [1] on the other. Deciding
it here makes it one diagnostic on both rather than one backend’s
silence; issue #168 is where the question it does not settle — what
such a parameter would mean — is written down.
Two things bound it, and both are abstentions rather than gaps in the
rule. A name this pass did not bind is not a place and is not reported as
one — see Checker::not_a_place. And a receiver whose type is an
unknown or a host type is left alone, because the interpreter reaches a
host resource’s own operations before it reaches any of this.
§What the runtime keeps
The interpreter’s own checks stay, as ADR 0004 says. One rule about a
call is left to it entirely, and it is worth naming because it is
decidable here and is not decided here: whether a var marking written
at a call site agrees with the one written at the declaration. A function
type carries no marking, so a call through a value has nothing to check
against, and the parameters this pass builds for a builtin, a host
operation and a struct’s initializer are not written with a marking at
all. ADR 0021 records it as unfinished rather than as decided.
§Where a form’s value comes from
docs/LANGUAGE_REFERENCE.md states one rule per expression form, and the
two that this pass and the interpreter used to answer differently are
stated there because they had to be decided rather than discovered:
- An
ifwith noelseproduces(), and its branch’s value is discarded. There is no second branch to give the missing case a value, so the branch that runs does not supply one either. - Every loop produces
(). Aforruns out of items and awhileruns out of condition, so a loop can reach its end without breaking and there is nothing at that end to produce but(); abreakoperand is checked on its own and its value discarded, exactly as anif’s branch value is. That a loop never carries a value is decided rather than pending: issue #87 settled it, and the Language Reference gives the reason.
The interpreter obeys both, so a checked program’s static and dynamic answers are the same one.
Structs§
- FnTy
- A function type:
fn(Int) -> Int,async fn() -> Result<Unit, Error>.
Enums§
Constants§
- ALIAS_
CYCLE - A type alias expands to itself.
- ARITY
- A call passes more arguments than the callee declares parameters.
- AWAIT_
OPERAND awaitwas applied to something that is not aTask.- BRANCHES
- The branches of an
ifor the arms of amatchproduce different types. - CONDITION
- A condition is not a
Bool. - CONFORMANCE_
SIGNATURE - A conformance’s method does not have the signature its trait declares.
- DYN_
ASSOCIATED - A trait method with no
selfwas called throughdyn Trait. - DYN_
MUTATING - A
var selftrait method was called throughdyn Trait. - ENTRY
- An entry function’s shape does not fit the host boundary.
- HOST_
TYPE - A type reached through a host module the schema does not describe (warning).
- INFERENCE_
CONFLICT - Two uses of a local binding ask for different types where its initializer left one open.
- ITERABLE
forwas given something it cannot iterate.- LABEL_
ORDER - A labeled argument fills a parameter that stands before one an earlier argument already filled.
- LAMBDA_
RETURN - A function value uses
return, with nothing saying what it produces. - LAYOUT_
CYCLE - A
structor anenumwhose value layout would contain itself. - MISMATCH
- An argument’s type does not match the parameter, field, or payload it is given to.
- MISSING_
ARGUMENT - A call omits a parameter that has no default.
- MISSING_
PARAMETER_ TYPE - A declaration’s parameter has no written type. Unlike a lambda’s, it has no expected type at a call site to infer from.
- NOT_
A_ PLACE - An expression written where a place is required is not one: an
assignment’s target, a
varargument, or avar selfreceiver. - NOT_
A_ VALUE - A type or a module is written where a value belongs.
- NOT_
CALLABLE - A call was made to something that is not a function.
- OPAQUE_
CONSTRUCTION - The synthesized labeled constructor of an
export opaque structis called outside the module that declares it. - OPAQUE_
FIELD - A field of an
export opaque structis named outside the module that declares it. - OPERATOR
- An operator is not defined for these operand types.
- PATTERN
- A pattern matches a different type than the scrutinee.
- PAYLOAD_
ARITY - An enum case is constructed with the wrong number of payload values.
- READ_
ONLY_ PLACE - A place
letmade read-only is written, passed asvar, or given to avar selfreceiver. - RECEIVER
- A method was called without a receiver, or an associated function with one.
- RECURSIVE_
TYPE - A use asks a binding to hold a type that contains itself, which this language writes by declaring a type rather than by inferring one.
- SCOPE_
CHILD_ FAILURE - A task nothing awaits can leave a
scopeasErr, and the function the scope was written in does not answer aResultthat can carry it. - TASK_
SAFETY - A type that may not cross a task boundary was written where one must.
- TEST
- A
test fndoes not have the shape the test runner calls. - TRY_
OPERAND ?was applied to something that is not aResultor anOption.- TRY_
RETURN ?propagates a failure the enclosing function cannot return.- TYPE_
ARGUMENTS - A generic type is given the wrong number of type arguments.
- UNBOUNDED_
PARAMETER - A method was called on a type parameter that declares no bound.
- UNCONSTRAINED
- Nothing written anywhere says what a type is: an unannotated lambda
parameter, an empty array literal, a bare
None, a struct’s type parameter no field mentions, or a binding whose uses settle nothing, in a place that expects nothing in particular. - UNCONSTRAINED_
FIELD - A host type’s field whose schema declares it
Any, so the checker can prove nothing about the value read from it (note). - UNCONSTRAINED_
RESULT - A host operation whose schema declares its result
Any, so the checker can prove nothing about the value it produced (note). - UNKNOWN_
ASSOCIATED - An associated call names no associated function of the type.
- UNKNOWN_
CASE - A qualified case names no case of the enum.
- UNKNOWN_
FIELD - A field access names no field of the receiver’s type.
- UNKNOWN_
HOST_ OPERATION - A host module’s schema declares no operation of that name, on the module or on one of its resources.
- UNKNOWN_
HOST_ TYPE - A host module’s schema declares no type of that name.
- UNKNOWN_
LABEL - An argument label names no parameter of the callee.
- UNKNOWN_
MEMBER - A qualified name reaches nothing an imported module exports.
- UNKNOWN_
METHOD - A method call names no method of the receiver’s type.
- UNKNOWN_
NAME - A lowercase name is not in scope.
- UNKNOWN_
TRAIT - A
dynor a bound names something that is not a trait this module can see. - UNKNOWN_
TYPE - A type name no module declares.
- UNRESOLVED_
NAME - A capitalized name no module declares and no
usereaches. - UNSATISFIED_
BOUND - A type argument does not conform to the bound its type parameter declares.
- UNSUPPORTED_
BOUND - A bound was written where the MVP does not check one.
- VARIADIC_
AS_ VALUE - A variadic host operation used as a value, which no function type in this language can describe (note).
- VARIADIC_
DEFAULT - A variadic parameter is written with a default.
- VARIADIC_
LAMBDA - A variadic parameter is written on a function value, whose parameters are its function type’s and so are a fixed list.
- VARIADIC_
POSITION - A variadic parameter stands somewhere other than last in its declaration’s parameter list.
Functions§
- check
- Type-checks a resolved program.
- check_
facts - Type-checks a resolved program against
schemas, keeping what the check worked out about each expression. - check_
with - Type-checks a resolved program against
schemas, the host modules this compilation may name.