0158 - The compiler infers operator results, proven consts and const function returns
Date: 2026-10-25
Area: compiler, bytecode
Related: ADR 0001, Bytecode VM § Compiler Type Inference, #1276
Context
The compiler typed an expression only from literals, annotated or literal-typed
bindings and arithmetic between two typed operands. Anything else, such as
a * 2 with an unknown a, the result of a unary operator or a call, was
untyped, and so was every binding initialized from it. The typed instructions
(OP_ADD_FLOAT, OP_EQ_FLOAT and the rest) do not check their operands, so a
type may only be claimed where the specification makes it certain.
Types also reach the bytecode: each function template records the type of every typed local. A consumer that translates bytecode ahead of time reads them, and untyped locals force it to keep every value tagged.
Decision
An operator's result type follows from ECMA-262, not from its operands' types
alone. With one operand a proven Number, -, *, /, % and ** yield a
Number: §13.15.3 ApplyStringOrNumericBinaryOperator applies ToNumeric to both
operands, and a Number with a BigInt throws. + is a Number only when both
operands are, and a String when either is a String. The bitwise operators
yield an integer Number unless both operands are BigInts (>>> always, since a
BigInt operand throws). Unary + is a Number, - and ~ of a proven Number
stay Numbers, ! is a Boolean and typeof a String.
A const is typed when Goccia.Compiler.NumericBindings proves its
initializer a Number. The whole-body proof that already decides which let
bindings hold only Numbers also answers for a const of a plain name. A
destructuring pattern is not asked: extending the scan to nested destructuring
patterns made it exceed the time limit on tests/language/nesting-limit/statements.js.
Under --strict-types a let whose initializer is not a literal form is typed
only when the proof covers every assignment, and the type is not enforced.
A call has the type of the const function it names, when every return has
one type. The compiler records the join of a function's returns while it
compiles the body. A function that can reach its end, returns without a value,
is async or a generator, or returns different types has no return type. The
type is used for a plain, non-optional call by identifier, only when the name
resolves to a const binding that is not global-backed, no with object is in
scope, and no sloppy direct eval can shadow the name. A captured let that
only ever holds Numbers keeps its type inside closures.
Not inferred: Math calls and calls on any global, since a global binding
is writable; a return-type annotation, which is not enforced (#1276); a var.
Consequences
- The VM runs the same code: for Numbers the typed and the generic arithmetic opcodes share one fast path, so no speed-up was measured on the benchmarks. The value is in the types the bytecode records, for ahead-of-time consumers.
- A local the compiler could not type before can now carry a type in its function template; no type that was already recorded changes.
- The integer type comes only from results that are an int32 or a uint32 by
specification: the bitwise operators,
~and>>>. No arithmetic result is an integer,+and-included, because a sum of integers can overflow toInfinity. The compiler used to give two integers an integer result for every arithmetic operator; that rule was dormant while nothing produced an integer, and typing bitwise results activated it, so it is removed. Goccia.Compiler.Test.paspins each rule and its negative cases in the default and the--strict-typesprofiles, through the local types and opcodes the compiler emits; no script can observe the inference.