ADR-007: Static types with local inference
The Rust implementation, pinned to de1b6c9e. Every builtin signature.
Status
Accepted - 2026-09-24
This ADR supersedes ADR-004 and builds on ADR-006. It is the first language decision made for the Rust implementation as the reference; the Go implementation will be deprecated and keeps the ADR-004 language.
Decision
Every Vibescript expression has a static type, and a program that does not type
check does not compile. Engine::compile, vibes run, vibes check, the REPL
and the language server all report type errors as compile errors, before any
script code runs.
Types come from four places only:
- Declarations. Every function parameter and every function that returns a value declares its type, and so does the block parameter of every function that yields. Properties, instance variables and typed locals declare theirs.
- Inference from the expression. A local takes the type of its first assignment and keeps it. Literals, operators and calls have types determined by their operands and signatures.
- Signatures of builtins and host capabilities. Every builtin function and member has a typed, possibly generic, signature. Host capabilities contribute their declared contracts.
- Narrowing of dynamic values. Values whose type is not known statically,
such as
JSON.parseresults and results of unsigned host functions, have typeanyand must be narrowed before use.
The governing principle is: every value’s type is known where it is used, and the only runtime type checks are at the edges where dynamic data enters.
Resulting language shape
def total(items: array<{ price: int, qty: int }>) -> int
sum = 0
items.each { |item| # item: { price: int, qty: int }
sum = sum + item["price"] * item["qty"]
}
sum
end
count = 1
count = "one" # compile error: count is int, got string
names: array<string> = [] # empty literals need a declared type
names << "Ada"
label: string? = nil # so does nil
label = "ready"
body = JSON.parse(raw) # any
body["name"].upcase # compile error: body is any; narrow it first
user = JSON.parse_as(raw, { name: string, age?: int })
user["name"].upcase # user["name"] is string
Context
ADR-004 chose gradual typing: unannotated code stays dynamic, the checker infers
facts where it can, and unknown values defer to runtime contracts. Implementing
that contract faithfully made the checker the largest and least predictable part
of the system. In the Rust implementation it is about 52,000 lines, plus 46,000
lines of tests, half of src/. To stay useful on unannotated code it performs
flow-sensitive abstract interpretation: literal and union facts, per-call-site
specialization of function bodies, loop fixpoints with widening, object-heap and
recursion summaries, and an explicit “incomplete” result for anything it cannot
yet model.
Each of those mechanisms has had to be hardened separately. One sweep over the reference test suite’s 7,890 programs found 320 reporting incomplete analysis, four that never converged, and program shapes whose checking cost grew cubically. All of these were fixed. None of them would exist in a checker that knows every value’s type from declarations and local inference.
Vibescript is written mostly by AI. Rules an author can apply locally, such as “annotate every parameter” and “a variable keeps its first type”, are easier for a model to follow than rules about which facts a gradual checker will manage to prove. A mandatory type check also turns every mistake into a compile-time diagnostic instead of a runtime failure in production.
ADR-004 rejected mandatory annotations for making small scripts heavier. With AI writing most scripts that cost is small, and the benefit is a smaller, faster checker with predictable results.
Design
Functions
- Every parameter, including optional, keyword and rest parameters, declares a
type:
def greet(name: string, times: int = 1, **opts: hash<string, any>). - Keyword parameters follow a bare
*or a rest parameter and are declared like positional ones:def send_email(to: string, *, cc: string? = nil, retries: int = 3)anddef join(*items: array<int>, sep: string = ","). Calls pass them by name, as insend_email("a@b.c", retries: 5). The formsname:,name: defaultandname: T:are removed (ADR-008):name: 2read as a keyword default whilename: intread as a typed positional parameter, and a typed keyword could not have a default. - A function that returns a value declares
-> T. A function without->returnsnil: its final expression is evaluated for effect only, andreturn valueinside it is an error. - A function is checked once, from its own signature and the signatures of what it calls. Callers never look at bodies. Checking is linear in the program and independent of declaration order.
- Functions are not generic; builtins are. A helper that would need a type
parameter takes
anyand narrows, or is written once per type. User generics may be proposed later in a separate ADR.
Locals
- A local is declared by its first assignment and keeps that type:
x = 1; x = "a"is an error, whether the second assignment is in the same block, a branch, a loop or a block body. name: T = valuedeclares a local with an explicit type. It is required when the initial value does not determine the intended type:nil,[],{}, or a value that should be stored as a wider union.- A local must be definitely assigned before it is read. A local first assigned in only one branch cannot be read after the branch.
- Assignment never converts.
intdoes not widen tofloat;numberis the unionint | float.
Unions, nil and narrowing
- Unions and nullability exist only where they are declared (
T?,A | B) or produced by an operation whose signature includesnil:array[i]andhash[key]areT?,findisT?, andx&.maddsnil. fetch(i)andfetch(key)returnTand raise when the element is missing.x == nil,x != nil,x.is_type?(:atom)and early returns narrow a local or parameter in the branches they guard. Conditions are strictlybool(ADR-008). Narrowing does not apply to member reads or index expressions: bind the value to a local first.
Collections
- An array literal’s element type is the union of its elements’ types:
[1, 2]isarray<int>,[1, "a"]isarray<int | string>. - A hash literal is an exact shape:
{ name: "Ada", age: 3 }is{ name: string, age: int }. Labels are string keys (ADR-006), so the fields are read ash["name"]andh["age"]. Reading a declared field with a literal key yields the field type, notT?. Reading or writing an undeclared key is an error, and so is indexing a shape with a key known only at runtime: a record is not a dictionary. The diagnostic’s fix declares the dictionary type when every field has the same type. - A dictionary is declared as
hash<string, V>:counts: hash<string, int> = {}. Keys are strings (ADR-006). A shape whose fields all have typeVis assignable tohash<string, V>. - Empty literals take their type from context: a declared local, a typed parameter, return, property or field, or an element of a typed collection.
- A tuple type
[A, B]is an array of exactly those elements, in order. Tuples exist only at compile time; their values are arrays. An array literal of matching length and element types is assignable to a tuple type, and indexing a tuple with an integer literal yields that element’s type. Builtins use tuples for fixed-length results and pairs:partitionreturns[array<T>, array<T>],divmodreturns[int, int], and hashto_areturnsarray<[string, V]>.
Dynamic values
anyis the type of values the program cannot know statically:JSON.parseresults, host globals declared without a type, results of host functions and capability methods without signatures, and values stored inany-typed containers.- An
anyvalue may be compared with==, tested with== nilandis_type?, passed or stored whereanyis accepted, and narrowed. Every other use is a compile error: calling a member, indexing, using an operator, or passing it to a typed parameter. - Narrowing happens through
is_type?in a condition, throughJSON.parse_as(raw, T), and through the checked castvalue.as(T). Both of the latter validate at runtime like a typed parameter, raise the same boundary error on a mismatch, and have typeT, which may be any type an annotation can name. A cast also narrows a declared union.
Blocks
Builtin members declare block signatures, generic where needed:
array<T>#maptakes a block(T) -> Uand returnsarray<U>. Block parameters take their types from the signature. Annotations on block parameters are optional and must match.A block’s result is checked against the signature’s result type.
A script function that uses
yielddeclares its block as a typed parameter, last in the parameter list:def keep(items: array<Item>, &block: Item -> bool) -> array<Item> kept: array<Item> = [] items.each { |item| kept << item if yield(item) } kept end def each_pair(h: hash<string, int>, &block: (string, int)) h.keys.each { |k| yield k, h.fetch(k) } end def maybe_log(msg: string, &block?: string -> nil) yield msg if block_given? endA single argument type needs no parentheses; several are parenthesized. Without
-> Rthe block’s value is discarded, and usingyieldas a value is an error.The block parameter’s name is a declaration only. Calling, storing, returning or passing it is a compile error;
yieldandblock_given?are the only ways to reach the block, so it still cannot escape (ADR-006).Each
yieldis checked against the declared argument types and has the declared result type. Callers’ blocks are checked against the declaration like builtin blocks, and calling a function whose block is required without one is a compile error.&block?:makes the block optional. Everyyieldmust then be guarded byblock_given?, which narrows like a nil check.
Builtin signatures
src/signatures/builtins.vibeis the signature table: every builtin function, namespace member and member of every value type, written as declarations and printed byvibes prelude(ADR-008).- Builtin signatures may be generic.
class array<T>bindsTto the receiver’s element type,def map<U>introducesU, andT: BrequiresTto be a single type assignable toB, soarray<int | string>has nosortorsum. - A builtin name may have several signatures. A call selects one by its number
of positional arguments, its keyword names, and whether it passes a block and
how many parameters the block declares, never by the types of its arguments:
firstreturnsT?andfirst(n)returnsarray<T>, and a hash’seach { |key, value| }andeach { |pair| }bind(string, V)and[string, V]. The table refuses an overload set in which one call could match two signatures. Script functions are not overloaded. regex,match_data(a successful match),error(whatrescue => errorbinds) andtype<T>(a type literal, such asJSON.parse_as’s second argument) are type names in annotations too.
Classes, enums and namespaces
- Properties, getters and setters declare their types. Other instance
variables are declared in the class body:
@count: int = 0gives each instance that default beforeinitializeruns, and@name: stringwithout a default must be assigned on every path throughinitialize. Reading or assigning an undeclared instance variable is an error. Class variables are declared the same way, with a value:@@count: int = 0. - Methods follow the function rules.
initializedeclares its parameter types. - Classes are nominal and have no inheritance (ADR-006), so there is no subtype
relation beyond unions,
nilandany. A nested class is named through its scope,Outer::Inner, in types as in values. - Each enum is a type. A symbol literal naming a member is accepted where the enum is expected; any other symbol is an error.
Host boundaries
Script::call(name, args)validates the host’s argument values against the function’s declared parameter types when the call starts, since host values are dynamic. The result has the declared return type.- Host functions and capabilities with signatures are typed by them. Without a
signature they accept
anyarguments and returnany. - A host declares the globals and capabilities each call supplies:
Engine::declare_global(name, type), andEngine::declare_capability, which types a capability’s methods by their published signatures and its data by its template’s values. A global declared without a type, and a capability built by a factory, areany. A bare name that is neither in scope nor declared is a compile error (V0201), notany.Engine::preludelists the declarations, and each call checks at entry that its globals and capabilities match them, as it checks arguments. - The CLI passes arguments as strings.
vibes run script.vibe a brequires the entry function’s parameters to acceptstring, or a rest parameter to acceptarray<string>, and reports a type error before running otherwise.
Runtime and accounting
- A typed boundary between two well-typed parts of a program is proven at
compile time and is not rechecked at runtime. Runtime type checks remain at
host entry, including declared globals and capabilities,
JSON.parse_as, checked casts, and capability results. - Step, memory and recursion accounting are unchanged. Sizes, loop counts and arbitrary-precision arithmetic stay dynamic, so every existing charge remains.
What goes away
- The gradual checker (
src/checking), its incomplete results, and its per-call-site analysis. vibes checkas a separate semantic gate: it becomes “compile and report”.run -check, the flat--check/--checkedflags andScript::check*become compile-time type checking, andchecked_callbecomes ordinarycall.- Runtime contract checks on proven internal boundaries.
Diagnostics
Type errors are reported in Rust-owned, stable wording with source positions, several per compilation where recovery is cheap. There is no Go compatibility constraint on type diagnostics.
Migration
- This is a breaking change, permitted before 1.0. Every script with an unannotated parameter, a type-changing assignment or an untyped empty literal must change.
- A
vibes migratecommand will propose annotations: parameter and return types observed while running a script’s tests or supplied inputs, plus the types the compiler infers for locals. The migration must cover the repository’s own corpus, tests, fixtures, examples and documentation. - The Go implementation stays on the ADR-004 language. Most annotations already
parse and run there, so the Go differential tools remain usable for runtime
semantics on well-typed programs Go can parse. Typed local declarations,
instance-variable declarations, block type parameters and
ascasts are new syntax that Go rejects.
Planned order: this ADR and the specification; parser support for typed locals, instance-variable declarations and block type parameters; the new type checker with builtin signatures; compile integration and removal of proven runtime checks; migration of the corpus; removal of the gradual checker; CLI, REPL, language server and documentation.
Consequences
Easier:
- Type checking is one linear, modular pass: no fixpoints, widening, summaries, heaps or incomplete results. The checker becomes a small fraction of its current size and runs in time proportional to the program.
- A program that compiles has no type errors except at explicit narrowing points and host entry.
- Authors, human or AI, follow local rules with local diagnostics.
- The runtime can skip checks the compiler has proven, and the language server gets exact types for hover and completion.
Harder, and what we now owe:
- Scripts carry more annotations, and dynamic JSON needs
JSON.parse_asor explicit narrowing. array[i]andhash[key]require nil handling orfetch.- Helpers that are naturally generic need
anyor per-type copies until user generics exist. - Every builtin member needs a complete typed signature, including generic block signatures, and that table becomes part of the language contract.
- The migration touches almost every test and example in the repository, and the REPL must keep binding types across lines.
Alternatives considered
Keep ADR-004’s gradual checker
Rejected. It works, but at the cost described under Context, and a clean result still does not prove the absence of type errors.
Crystal-style whole-program inference
Rejected. Crystal infers parameter types per call site and turns
x = 1; x = "a" into a union. That is the per-call-site analysis this ADR
removes, and it keeps a function’s types dependent on its callers.
Keep any gradual
Rejected. Allowing operations on any and checking them at runtime keeps the
checker simple, but it leaves runtime type errors possible wherever dynamic data
flows, and those are the paths that matter most in workflow scripts.
Declare block types after the return type
Rejected. A clause such as -> array<Item> yields (Item) -> bool puts a second
arrow after the return type and a new keyword in every yielding signature.
Declaring the block as a typed & parameter, as Crystal does, keeps the whole
contract in the parameter list; making its name a declaration only preserves
ADR-006’s rule that blocks never become values.
Infer block types
Rejected. Argument types could come from the yield sites, but the block’s
result type depends on each caller’s block, so the body would have to be
checked per call site. Crystal can do this because it inlines every yielding
method; checking each function once from its signature cannot.
Infer return types
Rejected. It saves one annotation per function, but makes checking depend on callee bodies and declaration order, and makes a function’s contract implicit.
Links
- Superseded: ADR-004: Infer local types and check typed boundaries statically
- Builds on: ADR-006: Slim the language for predictable sandboxing
- Current type syntax and runtime contracts: types
- The checker: checker
Implementation notes
The switchover
On 2026-09-26 static types became the only mode. Engine::new(), vibes run,
vibes check, the flat form, the REPL, the language server, the test runner
and the embedding examples type check every compile; the opt-in switches
(Engine::set_static_types, the --static flags and the
VIBESCRIPT_STATIC_TYPES build) are gone, and so are the gradual checker’s
command-line entry points (run -check, check without --static, the flat
form’s --check and --checked). The deferred runtime rules took effect
with it: / divides numbers to a float, a function without -> T returns
nil after evaluating its last expression for effect, and fill and
insert raise past the end of an array. V0109, which rejected / on two
ints while it still floored them, is retired; its number stays registered
and is not reused.
One escape hatch remained at the switchover, Engine::legacy_unchecked(),
which compiled the ADR-004 language without static types and with that
language’s runtime rules, so that vibes migrate could compile, run and
observe scripts written before it. Phase 5 deleted it the same day, with
everything only the old language used: the migrator and the observe feature
that fed it runtime types, the gradual checker and its Script::check family,
the runtime rules the escape hatch kept (/ flooring two integers, fill and
insert padding with nil, and every function returning its last
expression), and the runtime support for removed spellings (synonym members,
dispatch by name, nil?, eql? and equal?, tap and its relatives,
Hash.new, Regexp, sprintf, the global now, Time.gm, mktime and
new, hash fields written with a dot, symbol hash keys and the options-hash
rule). The migration’s tooling and decision log are in the repository’s
history, and the golden parse sweep now only parses.
The full grammar still reads the removed syntax, only so that the checker
reports it with its fix; a source the checker’s surface rules cannot read must
also parse in the canonical grammar, so removed syntax never compiles. Those
rules report a removed spelling however it is written. Before phase 5 they
missed one called with arguments its rewrite does not take, on a builtin
namespace or class instance, inside a computed callee or named bare, and such
code ran the removed support. The runtime’s selection of a computed call
target stays, although the checker rejects every computed call (V0310): it is
shared with calls of a member named call.
Decisions the ADRs left open:
- Two ints divide to the float nearest their exact quotient, ties to even,
whatever their size. A quotient beyond the float range raises an
arithmetic error; one below it is a subnormal or zero, whose sign follows
the operands’.
int.divanddivmodkeep flooring. - Only functions and methods return
nilwithout-> T. The top-level statements still produce the value of their last expression, whichScript::runreturns and the REPL shows, and so do namespace bodies and accessors. - A class’s own
==or!=has the type its method declares,nilwithout-> T, and its right operand is checked against the method’s parameter; a!=that the runtime answers by negating==is abool. Typing either asboolwhatever the method declares would let anilreach a typed position. fillnever grows an array: a window that ends past the end raises, and so doesinsertat an index past the end; inserting at the length appends.- The removed syntax of ADR-008 (
do ... end,unless,until, percent literals and thename:keyword parameters) no longer parses. A source that uses it and is otherwise well formed still reaches the checker, which reports the removed spelling with its fix, since the full grammar parses first; a source that does not parse either way reports the error the canonical grammar finds, where those words start nothing and%is only an operator. - A REPL session declares each variable with the type the checker gave it:
every input begins by binding the earlier variables as typed locals, cast
from one declared global. A variable whose class an input declares again
is bound as
any, since its value belongs to the replaced class. - On WASI, which has no threads to give the checker the 64 MiB stack it
runs on natively, a source whose syntax is more than 128 levels tall
fails to compile with
V0001rather than exhausting the default stack; native targets keep the parser’s limit of 1,024. - The golden cases whose purpose is a static rejection kept the outcome their goldens recorded in the ADR-004 language until phase 5; their goldens now record the compile error.
Namespace initializers run where the top-level module declaration is executed, with nested modules initialized before their parent. Their bodies can read and update locals already assigned in that active top-level frame. Namespace methods do not capture those locals. Calling a function directly through the host can initialize namespaces without executing the top-level statements; a namespace that depends on those statements therefore requires execution of the script’s top level first. Required files keep their top-level bindings in the file environment, visible to their functions.
A namespace block can read ambient top-level locals, but an assignment to one creates a block-local binding rather than capturing the top-level slot. A compound assignment therefore needs an earlier assignment in that block. Copy the value to a namespace-local name before the block when mutation must persist.
Capitalized assignments in namespace methods use shared namespace storage, while ordinary function and top-level bindings have different lifetimes. The static language rejects capitalized assignments inside functions; use lowercase locals or explicitly declared class variables. Namespace-body constants keep one type across writes.
Capability data members may be assigned and updated through an addressed path.
Every write keeps the data member’s declared type, including =, compound
assignment, appending and index assignment. Methods and nested capability
namespaces cannot be replaced. Collection updates preserve ADR-006 value
semantics: a copy previously read from a member is unchanged.
A class variable has no value until its initializer assigns it, even when its type accepts nil. A read or update before that assignment raises an initialization error instead of producing nil. This also applies to host snapshots captured during a namespace initializer: snapshots keep their partial state and never replay the initializer. Explicitly initialized nil remains valid only for a type that allows it.
Interactive hosts must declare retained class, module and enum values too.
declare_capability accepts these values as templates, pins their nominal
identity at call entry, and requires the carried source to keep the namespace’s
original declaration environment or enum’s members. For a namespace this
conservatively includes the declarations and root type aliases its source
references, transitively, since its methods still resolve that code’s
dependencies. Referenced classes, modules and enums must also be declared as
retained bindings so both scripts resolve the same supplied type. Changing a
dependency requires replacing the retained namespace too. This lets a REPL
retain typed values without allowing undeclared globals to replace compiled
declarations.
The typed VM
The runtime uses the static types (see the typed VM). A call
from script code no longer checks its arguments or its result, and typed
locals, yield arguments and block results are not checked again. A few
checks inside a program stay, because they do more than the checker
proves: a type naming a class or enum, which resolves at runtime and turns
a symbol into an enum member; a hash key type that no string satisfies,
such as hash<int, any>, which the checker admits; and the result of an
instance method of a class whose methods may read a property before it is
assigned, which reads as nil. The checker proves the rest of the
classes, and most instance variable writes, without refusing any program
it accepted before. Values whose static type holds no any, capability or
module skip the scan for host methods and exported functions that a bound
capability requires. Member calls on a receiver whose static base type is
hash, array, string, int or float call common builtins directly,
checking only the receiver’s runtime kind, and calls of a class’s methods
on a receiver the checker proves is its instance skip the lookup by name. Records stay ordinary hashes whose keys the literals of a program
share, one string per distinct text per call; field positions are not
fixed at compile time, because shape types are order-free while insertion
order is observable. Step counts dropped by the removed checks and
instructions, and the golden counters were re-recorded.