ADR-004: Infer local types and check typed boundaries statically
The Rust implementation, pinned to de1b6c9e. Every builtin signature.
Status
Superseded by ADR-007 - 2026-09-24. Accepted - 2026-07-09
Decision
Vibescript uses implicit local type inference on the check path. Locals take
the types of the expressions assigned to them, annotations are compile-time
facts as well as runtime contracts, and the checker reports an error wherever
known types contradict. The CLI exposes a whole-file gate through vibes check, an exact invocation gate through vibes run -check, and a whole-snippet
gate through vibes run -check -e.
The governing principle is: error on known contradictions, permit unknowns, and make unknowns easy to validate at boundaries.
To make boundary validation ergonomic, Vibescript provides
JSON.parse_as(raw, type): it parses JSON and validates the result against a
shape, container, scalar, nullable, or union contract in one step, and the
checker treats its result as that declared type.
This is still gradual typing, with a substantially better inference engine. It is not a switch to a whole-program proof system.
Context
Vibescript supports optional type annotations and enforces them at runtime. The
pre-ADR checker caught direct contract violations such as a string literal
passed to an int parameter:
def takes_int(value: int)
value
end
takes_int("1")
It did not carry local type facts through bindings:
def takes_int(value: int)
value
end
value = "1"
takes_int(value)
The shipped checker now rejects this program before execution. That is the contract required by deployment gates and AI-generated code: if a host can prove a typed-boundary error before deployment, it should reject it before runtime.
Crystal proves that a Ruby-shaped language can infer nearly everything, but its
inference works because every expression must ultimately have a compile-time
type. That requirement is exactly what makes JSON painful in Crystal: dynamic
payloads become JSON::Any, unions, casts, or declared serializable structs.
Adopting that model wholesale would reintroduce the same complexity precisely
where Vibescript’s main use case lives — host payloads, loose JSON, and
capability data flowing into short scripts. Vibescript also keeps Ruby-like
runtime semantics: values are dynamically tagged, hosts inject globals and
capabilities, and dispatch can be dynamic.
The problem, then, is to get Crystal’s useful inference for local code without inheriting its requirement that every value be statically typed.
Design
Locals receive inferred types, and known contradictions are static errors:
name = "Mauricio" # inferred string
count = 1 # inferred int
count = name # check error: count is int, name is string
Typed boundaries remain optional:
def create_user(name: string) -> User
User.new(name)
end
Dynamic inputs remain allowed:
body = JSON.parse(raw)
create_user(body["name"])
Here body["name"] is unknown statically, so vibes check accepts the call and
create_user validates the value at runtime, exactly as it does today. Unknown
values are never rejected merely because the checker cannot prove their type.
Apps wanting stronger guarantees validate once at the edge:
body = JSON.parse_as(raw, {
name: string,
email: string
})
create_user(body["name"])
After that validation, body is a known shape, body["name"] is a known
string, and everything downstream is inferred and checked. Shape-annotated
parameters already provide the same edge validation at function boundaries;
JSON.parse_as covers the common inline case where a payload is parsed and
consumed in the same scope.
Concretely, the checker maintains a local type environment while walking code:
- Assignments bind the inferred type of the right-hand side to the local.
- Sequential reassignment to a conflicting type is a static error; assignments in sibling branches merge into unions.
- Annotated parameters enter the function body with their declared type.
- Known function calls check argument types and expose annotated or inferred return facts to the caller.
- Annotated returns check the inferred type of explicit and implicit return expressions.
- Operators reject operands known to be invalid.
- Shape-typed values carry field-level facts, so indexing with a known key yields the field’s type.
nilchecks and supportedis_type?predicates narrow union types. Every finite known arm must satisfy a typed boundary; one compatible arm cannot hide another known mismatch.anyand unknown arms remain gradual and are checked at runtime, but they do not hide incompatible known arms.- Constructors and resolved class values carry nominal facts. Unknown or overrideable dynamic dispatch remains unknown.
- Known pure member calls and compatible modeled writes preserve mutable-container facts. Other known mutation, unregistered members, blocks, impure arguments, dynamic dispatch, and aliases to nested mutable values discard facts that the checker can no longer prove.
When the checker proves a violation, a check reports an error such as:
call to takes_int argument value expected int, got string
This design takes the useful parts of Crystal:
- Locals receive inferred types.
- Operators reject known-invalid operands.
- Calls and returns enforce declared contracts.
- AI-generated code gets concrete diagnostics from
vibes check.
And it preserves the useful parts of a scripting language:
- Function annotations remain optional.
- Untyped JSON and host values can flow dynamically.
- Unknown values are not rejected merely because the checker cannot prove their type.
- Runtime checks cover the uncertainty.
any remains an explicit escape hatch: it tells the checker to stop proving
facts about a value. The core rule does not change: known mismatches are static
errors; unknowns defer to runtime contracts.
Checking scopes
The same gradual rule is available at several scopes:
vibes check script.vibeandScript.CheckWarningscheck top-level code, functions, and class methods across the compiled script. This is the whole-file deployment and CI gate.vibes run -check [-function name] script.vibe [args...],CheckWarningsForFunction, andCheckWarningsForCallcheck the execution path of one function call.CheckWarningsForCallincludes the supplied positional arguments, keywords, globals, and capability contracts.vibes run -check -e 'source'checks the inline entrypoint and every function or method declared by the snippet, including declarations the entrypoint never calls.CheckedCallapplies the exact-call check and executes only when it produces no diagnostics. OrdinaryCallstill executes and relies on runtime contracts.
A clean result means only that the selected scope contains no contradiction the
checker can currently prove. It is not proof of full type safety: unknown JSON,
host values, opaque any or unknown union arms, and dynamic dispatch can still
fail at a runtime contract.
Non-goals
- No Crystal-style requirement that every expression in every script be statically typed before execution.
- No change to runtime value representation or Ruby-like dynamic dispatch.
- No removal of runtime boundary checks; the runtime remains the final guard for dynamic host data and unchecked paths.
- No whole-program inference across unknown host globals, arbitrary dynamic dispatch, or capability implementations.
Consequences
Typed annotations serve as checker facts while remaining runtime guards. Local contradictions that a user reasonably expects a type checker to catch are caught before execution, and AI-generated scripts get a tighter correction loop: wrong operators, wrong argument types, wrong return types, and shape mismatches produce concrete diagnostics before a host deploys the script.
The JSON path gets a one-step validated entry. A script that calls
JSON.parse_as at the edge gets full static checking downstream without any
other annotations.
The implementation is more complex. The checker maintains a local type
environment, joins branch facts, represents unknown values, and takes care not
to guess: when host data or dynamic dispatch cannot be proven, it defers to
runtime checks rather than rejecting the script. vibes check is a semantic
pass with its own compatibility surface.
JSON.parse_as carries specific costs: braced shape literals are legal as
first-class expressions when unshadowed, while non-shape type literals are
recognized only in parenthesized call arguments. Those paths add parser and
runtime surface, and validation failures must keep the same semantics as
existing typed-boundary errors.
Alternatives Considered
Keep the current gradual contract checker
Rejected. Runtime-only enforcement catches errors too late for deployment gates,
and the pre-ADR check path missed simple local contradictions such as
value = "1"; takes_int(value).
Adopt Crystal’s static model wholesale
Rejected. Full inference requires every expression to have a compile-time type,
which forces JSON::Any-style wrappers, casts, and declared structs onto
dynamic payloads — the exact data Vibescript scripts exist to handle. We want
Crystal’s inference for local code, not its obligations for dynamic data.
Require annotations everywhere
Rejected. Mandatory annotations make small scripts heavier and do not solve dynamic host data by themselves. Local inference gives most of the benefit for typed code while keeping scripts readable.
Lean on any for gradual typing
Rejected as the primary model. any is useful as an escape hatch, but making it
central would hide exactly the class of mistakes vibes check should catch, and
its contagious propagation rules complicate the language.
Treat unknown values as static errors
Rejected for the base checker. That would make gradual adoption and host-provided data painful. Deployment policies may require stricter annotations at entrypoints, but the language-level checker must distinguish “known wrong” from “not statically known.”