MEP 5. Type Inference
| Field | Value |
|---|---|
| MEP | 5 |
| Title | Type Inference |
| Author | Mochi core |
| Status | Informational |
| Type | Informational |
| Created | 2026-05-08 |
Abstract
Type inference in Mochi is local. Each expression form has a deterministic rule that maps operand types to a result type. There is no global solver and no constraint generation phase. This MEP documents the inference rules per construct so contributors can reason about a single expression without scanning the whole infer.go file.
Motivation
Inference rules are easy to break by accident. A new operator added to the grammar has no result type until someone decides one. A new collection literal has to interact with empty-literal hinting. Without a single record of the rules, contributors guess, and the guesses drift.
Specification
Entry points
types/infer.go:17-69.
ExprType(e *parser.Expr, env *Env) Type
ExprTypeHint(e *parser.Expr, hint Type, env *Env) Type
ExprType is the basic inference call. ExprTypeHint accepts a hint and uses it to type empty list and map literals where context is the only signal. Both return AnyType{} when no better answer is found.
The expression entry point delegates to inferBinaryType, which flattens precedence, then inferUnaryType, inferPostfixType, and finally inferPrimaryType.
Operator precedence and inference
types/infer.go:89-198. The order of resolution:
1. * / %
2. + -
3. < <= > >=
4. == != in
5. &&
6. ||
7. union union all except intersect
Within a level the algorithm reduces left to right. The result type table per operator is in MEP 4 under "numeric tower" and the same logic in code at types/infer.go:114-191.
A subtle order dependency exists in numeric mixes. Because the algorithm folds left to right, bigint - float and float - bigint can produce different result types depending on rule firing order. Fixtures for each pair are required (see MEP 9).
Inference for declarations
let x = e -> Γ' = Γ ∪ { x : ExprType(e) }, immutable
var x = e -> Γ' = Γ ∪ { x : ExprType(e) }, mutable
let x : T -> Γ' = Γ ∪ { x : T }, immutable
let x : T = e -> Γ' = Γ ∪ { x : T },
require unify(T, ExprType(e))
var x : T = e -> Γ' = Γ ∪ { x : T },
require unify(T, ExprType(e))
let with neither type nor value raises T000.
fun f<G1, G2>(p: T) : R { ... } introduces type variables in scope for the function body but does not generalise them across calls today. See MEP 12.
Inference for control flow
if cond { ... }requirescond : bool(T040). The body inherits the parent environment.while cond { ... }requirescond : bool.for x in source { ... }:- if
source : [T]thenx : Tin the body. - if
source : {K: V}thenx : Kin the body. - if
source : int(range form) thenx : int. - any other shape raises
T022.
- if
for x in a..b { ... }requires both bounds: intand bindsx : int.
Inference for collections
[]with no hint has type[any].[e1, ..., en]has type[T]whereTis the equal type of alleiif they agree, elseany.[]hinted with[T]has type[T].{}with no hint has type{any: any}.{k1: v1, ..., kn: vn}has type{K: V}whereKis the equal type of allki(elseany) and same forV.xs[i]requiresxs : [T] | string | {K: V}andi : intfor list and string, ori : Kfor map. ReturnsT,string, orV. List index out of range is a runtime error.xs[a:b]requiresxs : [T]orstringand bounds: int. Returns the same kind. Maps reject slicing (T017).
Inference for queries
The query type is computed in types/check.go via the query plan builder. The shape:
from x in xs where xs : [T]
let xprime : T the iteration variable
{ from y in ys }* additional sources stack as nested loops
{ join z in zs on c }* join clauses
[ where p ] require p : bool
[ group by k into g ] g : group<K, T>
[ sort by s ]
[ skip n ] [ take n ] require n : int
select [ distinct ] r result type list of ExprType(r)
The final result is ListType{Elem: ExprType(r)}. There is no narrower relation between sources and the projection result; the projection can produce any structurally typed value.
Aggregate functions inside select are special cased:
count(g) : intwheregis a list or group.sum(g) : int | float | bigint | bigratmatching the element type.avg(g) : float | bigrat.min(g),max(g) : Twhereg : [T]andTis comparable.
Anything else falls through to general expression inference. The fall-through is part of why MEP 10 flags loose verification of select results.
Inference for matches
match e { p1 => r1, ..., pn => rn }.
- The result type is the join of
ExprType(ri). With the current join that almost always means the common type orany. - Pattern shapes:
- Literal pattern: requires the literal type to be equal to
ExprType(e). Pattern binds nothing. - Identifier pattern: binds the name to
ExprType(e). - Underscore: binds nothing, never fails.
- Variant call pattern: requires
eto have aUnionTypecontaining the named variant. Binds field positions to the variant fields.
- Literal pattern: requires the literal type to be equal to
Exhaustiveness is not enforced today. See MEP 13.
Inference for casts
e as T. The result type is T. There is no compatibility check between the source and target type. This is a documented hole.
Inference for function calls
f(a1, ..., an). Requires Γ(f) to be a FuncType. Then:
- If
fis variadic withmformal params, the firstm - 1args unify normally and the rest must each unify with the last param. - Otherwise
nmust equalm(elseT006orT039). - Each
aiis checked againstΓ(f).Params[i]. Mismatch yieldsT007. - The call's type is
Γ(f).Returnafter substitution.
For pure functions, the Pure flag is set but the checker does not yet require pure call sites in any context. The flag is reserved for future enforcement.
Inference for tagged unions
A union type U = A(x: int) | B(s: string) enters the environment as:
Ubound toUnionType{Name: "U", Variants: {"A": ..., "B": ...}}.Abound to a constructor function(int) -> U.Bbound to a constructor function(string) -> U.
So let u = A(7) gives u : U. Pattern matching is the only way to recover the variant in a checked way today.
Inference for I/O expressions
load p as T with opts. Type is[T]if the format is "json" with an array file,Tif format is "json" with a single object, and[T]for "csv". The default format is "csv".save x to p with opts. Type isvoid.fetch url with opts. Type isany(the body of the response is decoded based on options).
Inference for closures and lambda
fun(p1, ..., pn) : R => body produces type FuncType{Params: [P1, ..., Pn], Return: R, Pure: false}. With a block body the return type is inferred from the trailing return statements. Free variables in the body are captured by reference, which has implications for mutation that MEP 15 documents.
Rationale
Local inference is enough for a small language. Global constraint solving would buy us better generic inference but at the cost of harder-to-explain error messages. We prefer to keep error messages tied to concrete source positions.
The "join" we use for if branches and match arms is conservative: we widen to any when in doubt. That is part of why MEP 10 calls out any as the most common loss of information.
Backwards Compatibility
Informational. No backward compatibility implications.
Reference Implementation
types/infer.go:17-69— entry points.types/infer.go:89-198— operator precedence and inference.types/infer.go:114-191— operator result types.types/check.go— query plan builder and special-cased aggregates.
Open Questions
- Tighter join. Should the join of two arms produce the union of their types instead of
any? That would tighten match results but requires anonymous union types. - Generic call inference. Currently call sites do not unify type variables across positions. MEP 12 proposes the change.
- Aggregate fall-through.
selectfalls through to general expression inference for non-aggregates. We should rejectanyprojections in strict mode.
References
Copyright
This document is placed in the public domain.