Type Inference
SAIL uses a constraint-based bidirectional type inference algorithm. The algorithm divides type information into two flows: a bottom-up flow that generates a type from an expression, and a top-down flow that pushes an expected type down into an expression. Both flows run simultaneously through unification, which merges type information from different sources into a consistent solution.
The scope of inference is deliberately limited. Top-level function signatures require complete explicit annotations. Inference operates freely inside function bodies: local variable types, intermediate expression types, and the types of arguments to generic functions are all inferred automatically without any annotation.
What Requires Annotations
Every top-level function must carry a complete type signature: every parameter must be annotated and the return type must be declared. The compiler rejects any top-level definition that omits an annotation. This rule applies to free functions and to functions defined inside a contract body.
// Required: every parameter and the return type are annotated.
function transfer(to : word, amount : word) -> () {
let bal : word;
assembly { bal := sload(caller()) }
assembly { sstore(caller(), sub(bal, amount)) }
assembly { sstore(to, add(sload(to), amount)) }
}
For a full description of the annotation requirement and the error it produces, see Functions.
What Is Inferred
Inside a function body, the compiler infers types for:
- Local variables declared with
let, whether or not they carry an annotation. - Intermediate expressions and subexpressions.
- Type arguments to polymorphic function calls.
- Type class constraints required by the body.
The inferred types are propagated through unification. Whenever the type of an expression is used in a position where another type is expected, the two are unified: the algorithm finds the most general substitution that makes them equal, or reports an error if no such substitution exists.
Local Variable Inference
A let declaration without a type annotation introduces a fresh type variable.
The compiler assigns a concrete type to that variable as soon as enough
information is available from the surrounding context.
Inferred from initialiser
When a let declaration includes an initialiser, the type is taken from the
initialiser expression. Integer literals always have type word.
function demo() -> word {
let amount = 100; // amount : word, from the integer literal
let flag = true; // flag : bool, from the boolean literal
return amount;
}
Inferred from subsequent use
When no initialiser is present, the type is inferred from the first use of the variable.
function loadBalance(account : word) -> word {
let bal : word; // annotated; no inference needed
assembly { bal := sload(account) }
return bal;
}
function compute(account : word) -> word {
let x; // no annotation, no initialiser
x = sload(account); // first assignment: x : word
return x;
}
Inferred from return context
A variable whose type depends on an algebraic data type can have its type fixed by the expected return type.
data Result = Ok(word) | Err(word);
function demo() -> Result {
let x = Result.Err(0); // x : Result, inferred from constructor
return x;
}
Contextual Constructor Shorthand
A constructor expression written as .Constructor (with a leading dot and no
type prefix) is resolved using the expected type at the point of use. The
compiler uses the context to determine which type the constructor belongs to.
In a return statement
The declared return type provides the expected type:
data TxStatus = Pending | Confirmed | Reverted;
function initialStatus() -> TxStatus {
return .Pending; // resolved as TxStatus.Pending from the return type
}
In a typed assignment
The declared type of the left-hand side provides the expected type:
data TxStatus = Pending | Confirmed | Reverted;
function demo() -> TxStatus {
let s : TxStatus;
s = .Confirmed; // resolved as TxStatus.Confirmed from the declared type of s
return s;
}
Error: ambiguous shorthand
If no expected type is available, the shorthand cannot be resolved and the compiler reports an error:
data TxStatus = Pending | Confirmed | Reverted;
function bad() -> word {
let x = .Pending; // no expected type available for x
return 0;
}
Cannot resolve shorthand constructor expression without expected constructor type:
.Pending
The fix is to annotate the variable: let x : TxStatus = .Pending;.
Integer Literals
An integer literal always has type word. There is no numeric type class or
overloading for integer literals in SAIL. Every integer literal that appears
in source code is a 256-bit EVM word value.
function demo() -> word {
let amount = 1000;
let decimals = 18;
let mask = 0xffffffffffffffffffffffffffffffffffffffff;
return amount; // amount, decimals, mask all have type word
}
Polymorphic Function Call Inference
When a polymorphic function is called, the compiler instantiates the type variables from the types of the supplied arguments. No explicit type application is needed.
forall a . function id(x : a) -> a {
return x;
}
function demo() -> word {
return id(42); // a instantiated to word at this call site
}
For a pair function with two type variables, both are instantiated independently:
data Pair(a, b) = Pair(a, b);
forall a b . function fst(p : Pair(a, b)) -> a {
match p {
| Pair(x, y) => return x;
}
}
function demo() -> word {
return fst(Pair(42, true)); // a = word, b = bool
}
Constraint Inference
When a function body calls a type class method, the compiler generates a constraint on the type variable involved. If the function is top-level and already carries an annotation, the annotation must list the constraint explicitly. If the annotation omits the constraint, the subsumption check catches the mismatch.
At a call site where the type variable is fixed to a concrete type, the compiler checks that an instance exists for that type. If no instance is found, the compiler reports an unsolved constraint error.
Constraint resolved at call site
forall a . class a:Encodable {
function encode(x : a) -> word;
}
instance word:Encodable {
function encode(x : word) -> word { return x; }
}
forall a . a:Encodable => function encodeField(x : a) -> word {
return Encodable.encode(x);
}
contract ERC20 {
function main() -> word {
return encodeField(42); // a = word; word:Encodable resolved
}
}
Error: no instance for the required type
If the concrete type at the call site has no matching instance, the compiler reports which constraint could not be satisfied and which instances are defined:
forall a . class a:SafeArith {
function safeAdd(x : a, y : a) -> a;
}
// No instance for word is declared.
function bad(x : word, y : word) -> word {
return SafeArith.safeAdd(x, y);
}
Cannot entail:
word : SafeArith
using defined instances:
The fix is either to declare instance word:SafeArith { ... } or to add the
constraint to the calling function's signature so the obligation is propagated
to the caller:
forall a . a:SafeArith => function bad(x : a, y : a) -> a {
return SafeArith.safeAdd(x, y);
}
Phantom Type Variables
A phantom type parameter is a type parameter of a data type that does not appear in any constructor field. When a phantom constructor is used inside a function body and the phantom parameter cannot be determined from the context, the compiler reports an ambiguous type variable error.
data TypedSlot(a) = TypedSlot(word); // a is phantom: it appears in no field
function bad() -> word {
let s = TypedSlot.TypedSlot(0); // a is unconstrained; no context fixes it
return 0;
}
Ambiguous type variable(s) $1 in definition of bad.
This typically occurs when a constructor has phantom type parameters.
Please, add a type signature to fix the ambiguous type variable.
The fix is to annotate the let declaration with the full type, giving the
phantom parameter a concrete value:
function good() -> word {
let s : TypedSlot(word) = TypedSlot.TypedSlot(0);
return 0;
}
Unification Errors
A unification error occurs when two types that must be equal turn out to be incompatible. The compiler reports the two types and the expression that triggered the failure.
Return type mismatch
function bad(amount : word) -> bool {
return amount; // amount : word; expected bool
}
Types: bool and word do not unify
- in: function bad (amount : word) -> bool { return amount; }
Match arm return type mismatch
All arms of a match expression must produce the same type. Returning
different types in different arms is a unification error:
data Result = Ok(word) | Err(word);
function bad(r : Result) -> word {
match r {
| Result.Ok(v) => return v;
| Result.Err(_) => return false; // word expected; bool returned
}
}
Types: bool and word do not unify
- in: false
- in: function bad (r : Result) -> word { ... }
Algebraic data type vs primitive mismatch
User-defined types and primitive types such as word are never
interchangeable:
data TxStatus = Pending | Confirmed;
function bad(n : word) -> TxStatus {
return n; // word is not TxStatus
}
Types: TxStatus and word do not unify
- in: function bad (n : word) -> TxStatus { return n; }
Scope of Inference
Inference is local to function bodies. Each function is checked independently, and no type information flows between sibling functions except through their declared signatures. The declared signature of a function is the only interface that callers see; the body is invisible to inference in other functions.
This means that the order of function definitions in a file does not affect the types that inference assigns to expressions inside any given function. Mutually recursive functions are type-checked as a group (see Functions), but even then each function's signature must carry complete annotations.