# RFC 0006 — Compile-time detection of provably-wrong code (success typing) - **Status**: Implemented. Core-fn error domains (arithmetic on non-numbers, count/first/rest/next/seq/nth on non-seqable scalars), `JOLT_TYPE_CHECK= off|warn|error`. Follow-ups landed: bounded scalar **unions** (jolt-pz5) so a use is reported only when every member is in the error domain; **user-fn error domains** behind `JOLT_TYPE_CHECK_USER` (jolt-zo1, closed-world); precise **file:line:col** locations (jolt-fqy). The checker is now one inference walk (folded into `infer`), and is **on by default in direct-link builds** — where it piggybacks on the specialization inference for ~free — and opt-in (`JOLT_TYPE_CHECK`) in plain builds. - **Champions**: jolt maintainers - **Created**: 2026-06-13 - **Depends on**: RFC 0005 (structural collection-type inference) ## Summary Reuse the structural type inference of RFC 0005 as a **loose type checker**: at compile time, flag code that is *provably* wrong, accept everything that is merely ambiguous, and never produce a false positive. Concretely, when an expression's inferred type is concrete and the operation applied to it would throw at runtime for that type (for example passing a string where a function only ever operates on numbers), report a clear compile-time error pointing at the offending form, with the inferred type and what was expected. When the type is `:any`, a union that includes a valid case, or beyond the inference's depth cap, accept it silently. This is **success typing** (the discipline behind Erlang's Dialyzer), applied to jolt for free on top of the inference we already need for optimization. ## Motivation Once the compiler tracks concrete types for many values (RFC 0005), it can see some programs that cannot possibly be correct: `(inc "x")`, `(first 5)`, `(count :k)`, `(/ 1 "two")`. Today these compile and fail at runtime, often far from the cause. Reporting them at compile time, with a precise location and message, turns a class of runtime crashes into immediate, actionable feedback, at no extra inference cost. The design constraint the user set is the right one and is exactly success typing's contract: **accept ambiguous cases, reject only provably-wrong ones.** A checker that never lies about errors is one developers trust and that does not get in the way of correct-but-untypeable dynamic code. ## Principle: success typing, never a false positive Success typing (Lindahl and Sagonas, "Practical Type Inference Based on Success Typings", 2006; the basis of Dialyzer) inverts the usual type-checker stance. A normal checker accepts only what it can prove correct and rejects the rest (false positives on dynamic code). A success typer accepts everything that *could* be correct and rejects only what *cannot* be correct under any execution. It is sound for **rejection**: if it reports an error, the code is genuinely wrong. It is intentionally incomplete: it misses errors it cannot prove. That is the correct trade for a dynamic language, and it matches the user's "accept ambiguous, reject provably wrong". Mapped onto jolt: - The inference assigns a value a concrete type only when it can prove it (RFC 0005). Unprovable is `:any`. - A use site is reported **iff** the argument's inferred type is concrete and lies entirely outside the operation's accepted domain, where the operation *throws* on that domain (not merely returns a benign default). - `:any`, a depth-capped child, or a union that includes an accepted type is **never** reported. ## What "provably wrong" means The checker needs, per operation it understands, an **error domain**: the set of argument types for which the operation throws at runtime. This is narrower than "the types it is documented to accept", because Clojure is lenient in many places and flagging a benign case would be a false positive: - `(get 5 :k)` returns `nil`, it does not throw. NOT reported. - `(:k 5)` returns `nil`. NOT reported. - `(count 5)` throws ("count not supported on number"). Reported when the argument is provably a non-countable scalar. - `(first 5)` throws (not seqable). Reported for a provably non-seqable scalar. - `(inc "x")`, `(+ 1 "x")` throw. Reported when an argument is provably a non-number (`:str`, `:kw`, `:struct`, `:vec`, ...). - `(nth 5 0)` throws. Reported for a provably non-indexable scalar. So the checker ships a curated table of the clearest throwing operations with their error domains. It starts small (arithmetic on non-numbers, seq/`count`/ `nth`/`first` on non-seqables) and grows conservatively. Anything not in the table is not checked, which is safe (no false positive). A use site is reported only when: 1. the argument's inferred type `T` is concrete (not `:any`, not a union that includes an accepted type, not truncated by the depth cap), and 2. `T` is in the operation's error domain (the operation provably throws on `T`). ## Examples ```clojure (inc "x") ; ERROR: inc expects a number, got a string (let [n "x"] (inc n)) ; ERROR: same, n inferred :str (count :foo) ; ERROR: count not supported on :kw (first 42) ; ERROR: 42 is not seqable (:k 5) ; accepted (returns nil, not an error) (inc (rand-nth coll)) ; accepted if the element type is :any/unknown (inc (if c 1 "x")) ; accepted: union {:num, :str} includes :num (ambiguous) (defn f [n] (inc n)) ... ; if f is ALWAYS called with strings in-unit, ERROR at the call; ; if its callers are unknown/varied, accepted ``` ## Error reporting A reported error includes: - the source location (`file:line:col`) of the offending form; - the operation and the parameter position; - the inferred type of the argument, rendered readably (`:str`, `{:struct {:r :num}}`, `{:vec :any}`); - what the operation requires (`a number`, `a seqable`). Example: ``` type error scene.clj:42:18: `inc` requires a number, but argument 1 is a string ``` Errors are attributed to the form the user wrote. For macro-expanded code, the checker reports at the original form's recorded position (the loader already tracks `:error-pos`), never at synthesized internals. ## Strictness levels `JOLT_TYPE_CHECK` controls behavior: - **off** — no checking. - **warn** — report to stderr, do not fail compilation. **The default in direct-link builds**, where checking rides the specialization inference for ~free; opt-in elsewhere. - **error** — fail compilation on a provable type error. Opt-in for CI / strict builds. When `JOLT_TYPE_CHECK` is unset, checking is **on (`warn`) in direct-link builds** and **off in plain REPL/dev builds** (where it would cost a standalone inference pass, ~2.6× compile). `JOLT_TYPE_CHECK_USER` additionally enables reporting against inferred user-function domains (closed-world; see below). Because the checker only fires on provable errors, even `error` mode cannot break a correct program: a correct program has no provable type errors to report. (A correct-but-untypeable program is simply not reported, since its types degrade to `:any`.) ## Soundness of rejection (no false positives) The whole value of this feature is that a reported error is real. The guarantees: - The inference assigns concrete types only when provable (RFC 0005). So a concrete `T` at a use site is a genuine lower bound on what flows there in the analyzed world. - The error-domain table lists only operations that genuinely throw on the listed types, verified against the runtime. - Ambiguity is always accepted: `:any`, unions containing an accepted type, and depth-capped children are never reported. Two boundaries need care and bound where the checker is allowed to fire: - **Closed-world / redefinition.** Inter-procedural argument types assume the compiled unit is the whole program (inherited from RFC 0005). For the checker, this means a reported error on a *user* function's parameter is only as sound as that assumption. The conservative initial policy: only report against **core-function** error domains (stable, not redefinable) and against types derived without crossing an open boundary. Reporting against inferred user-fn signatures is a later, opt-in escalation. - **Macros / generated code.** Check post-expansion IR but report at the user's source location, and suppress reports inside expansions the user did not write (or attribute them to the macro call site). ## Relationship to other systems - **Dialyzer / success typing** (Erlang): the direct model — sound for rejection, no false positives, accepts the ambiguous. - **Typed Clojure / core.typed**: opt-in *sound* gradual typing that rejects what it cannot prove correct; the opposite trade (false positives on dynamic code), which is why we do not follow it. - **clj-kondo**: a popular Clojure linter that flags some obvious type misuses syntactically; this RFC subsumes the type-driven subset with inference-backed precision and no false positives. ## Implementation The checker is a thin pass over the same inference results: 1. After (or during) inference, walk the IR. At each call to an operation in the error-domain table, look at the inferred type of each checked argument. 2. If concrete and in the error domain, record a diagnostic with location, the inferred type, and the expected domain. 3. Emit diagnostics per the strictness level. It adds no new inference; it consumes RFC 0005's types and a small curated table. It can ship after RFC 0005 lands, starting in `warn` mode with the smallest high-confidence table (arithmetic and seq/count/nth/first), and grow. ## Design problems and open questions - **Curating the error domain.** The table must list only genuinely-throwing cases. Getting it wrong (listing a lenient op) yields false positives, which destroys trust. Mitigation: start tiny, test each entry against the runtime, grow slowly. Open question: derive the table from the same machinery the runtime uses, to avoid drift? - **Unions.** *Resolved (jolt-pz5).* The lattice has a bounded scalar union `{:union #{T...}}` (cap 4); differing if-branches form a union instead of collapsing to `:any`, and a use is reported only when *every* member is in the error domain. Unions are opaque to structural specialization, so codegen is unchanged. - **User-function signatures.** *Resolved (jolt-zo1), opt-in.* Behind `JOLT_TYPE_CHECK_USER`: the checker re-checks a registered non-redefinable user fn's body with one parameter bound to its concrete argument type; a diagnostic the all-`:any` body did not have means that argument is provably wrong. Monotonic, so still no false positives; closed-world, hence opt-in. - **Negative/never types.** *Resolved (jolt-wwy).* Calling a provably non-callable value (`:num`/`:str` — keywords/maps/vectors/sets are IFn) is reported at the default level; wrong-arity to a registered single-fixed-arity user fn is reported under the `JOLT_TYPE_CHECK_USER` opt-in. A union callee is flagged only when every member is non-callable. - **Position vs intent.** *Resolved (jolt-fqy).* The reader records each list form's absolute offset (identity-keyed, so positions survive macroexpansion exactly when the user's sub-form is spliced through); the analyzer stamps it onto `:invoke` nodes, the checker carries it into each diagnostic, and the back end renders `file:line:col`. Inlining/scalar-replace preserve it via `assoc`. - **Interaction with the optimization gate.** *Resolved (jolt audit).* The checker is one inference walk folded into `infer`. In direct-link builds it piggybacks on the specialization inference that already runs (~free, default on); in plain builds it runs as a standalone pass only when `JOLT_TYPE_CHECK` is set. "Run inference for checking" and "specialize from inference" are the same walk now, gated by a `checking?` flag.