Chapter 6: Diagnostics as Accumulators — Collecting Errors Without Returning Them
Our type checker returns Type from infer_type. When something goes wrong — a type mismatch, an undefined variable — it returns Type::Error. But the caller has no details. Was it “expected Number, got String”? Was it “undefined variable ‘x’”? No way to know, and no way to report it to the user.
You might be tempted to change infer_type to return Result<Type, Diagnostic>. Don’t. That approach poisons the query graph: one error in a dependency forces every caller to handle the Err case, and Salsa’s caching becomes nearly useless (a function that sometimes returns Ok and sometimes Err can’t be cached reliably across revisions).
Salsa’s solution is accumulators: a tracked function “accumulates” diagnostics as a side effect of running, and the caller reads them out later. The function still returns Type — clean, cacheable, deterministic. The diagnostics are stored separately, and they’re tracked by Salsa too.
The Problem: Errors That Poison the Graph
You wrote a type checker. It returns Type. What happens when there’s a type error?
Option 1: Return Result<Type, Error>
If infer_type returns Err, downstream queries can’t use the result. The error “poisons” everything that depends on it. You lose all the type information you did compute. In a gradual type system, partial type info is valuable — you want to keep it.
Option 2: Return Type::Error (sentinel value)
Better. Downstream queries see Error and can decide what to do (propagate it, ignore it, etc.). But you lose the error message. You know something went wrong, but not what. “Cannot add Number and String” is gone — all you have is Type::Error.
Option 3: Accumulators (the Salsa way)
The query returns a Type (possibly Type::Error), and emits a diagnostic as a side effect. The diagnostic doesn’t affect the return value or the query graph. It’s collected after the query runs. You get both the type information AND the error message.
Step 1: Define the Diagnostic Types
First, we need a way to classify diagnostics. Most type errors are hard errors, but some are warnings (like unused variables). A simple enum does it:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
}
Now the accumulator itself:
#![allow(unused)]
fn main() {
use salsa::Accumulator;
#[salsa::accumulator]
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub severity: Severity,
pub message: String,
}
}
An accumulator is a type that can be “pushed to” from inside tracked functions. It’s a side channel — separate from the return value.
The Diagnostic struct has a convenience constructor for the common case:
#![allow(unused)]
fn main() {
impl Diagnostic {
fn error(msg: impl Into<String>) -> Self {
Diagnostic { severity: Severity::Error, message: msg.into() }
}
fn warning(msg: impl Into<String>) -> Self {
Diagnostic { severity: Severity::Warning, message: msg.into() }
}
fn emit(self, db: &dyn salsa::Database) {
self.accumulate(db);
}
}
}
The emit method is a thin wrapper around accumulate, which comes from the salsa::Accumulator trait. You could call .accumulate(db) directly — emit is a readability shortcut.
Step 2: Emit Diagnostics from Inside Queries
#![allow(unused)]
fn main() {
use salsa::Accumulator;
#[salsa::tracked]
pub fn infer_type(db: &dyn salsa::Database, source: SourceFile, expr: Expression, env: TypeEnv) -> Type {
match expr {
Expression::BinaryOp { ref left, op, ref right } => {
let lt = infer_type(db, source, (**left).clone(), env.clone());
let rt = infer_type(db, source, (**right).clone(), env.clone());
match op {
BinOp::Add => {
if !lt.is_compatible_with(&Type::Number) {
Diagnostic::error(format!("cannot use {:?} in arithmetic", lt))
.emit(db); // side effect!
}
if !rt.is_compatible_with(&Type::Number) {
Diagnostic::error(format!("cannot use {:?} in arithmetic", rt))
.emit(db);
}
if lt == Type::Error || rt == Type::Error { Type::Error } else { Type::Number }
}
// ...
}
}
// ...
}
}
}
When we find a type error, we do two things:
- Return
Type::Error— tells downstream queries “something went wrong” - Emit a
Diagnostic— tells the user what went wrong
These are separate concerns. The return value is for the query graph. The accumulator is for the human.
Step 3: Collect Accumulated Diagnostics
The file-level query is now called type_check — a shorter name now that it does more than type-check alone. In Chapter 5, we called this type_check_program. Same function, shorter name.
#![allow(unused)]
fn main() {
type_check(&db, source);
let diags: Vec<_> = type_check::accumulated::<Diagnostic>(&db, source)
.into_iter()
.cloned()
.collect();
}
After running type_check, you call type_check::accumulated::<Diagnostic> to get all diagnostics that were emitted during that query (and any queries it called). This is the read side of the accumulator.
Step 4: Incremental Accumulators
When a query re-runs, its old accumulated diagnostics are discarded and replaced. No stale diagnostics. If you fix a type error and the query re-runs successfully, the diagnostic from the previous revision disappears automatically.
#![allow(unused)]
fn main() {
use salsa::Setter;
use salsa::Accumulator;
// Bad program → diagnostics
bad.set_text(&mut db).to("local x = 42\nlocal y = \"hello\"\nlocal z = x + y\n");
type_check(&db, bad);
let bad_diags = type_check::accumulated::<Diagnostic>(&db, bad);
assert!(!bad_diags.is_empty());
// Fix it → no diagnostics
bad.set_text(&mut db).to("local x = 42\nlocal y = 1\nlocal z = x + y\n");
type_check(&db, bad);
let fixed_diags = type_check::accumulated::<Diagnostic>(&db, bad);
assert!(fixed_diags.is_empty());
}
The Pattern
┌──────────────────────────────────────────────┐
│ TRACKED FUNCTION │
│ │
│ input ──→ computation ──→ return value │
│ │ │
│ └──→ accumulate(diagnostics)│
└──────────────────────────────────────────────┘
Return value → used by downstream queries (the graph)
Accumulator → used by the human (the report)
These are intentionally separate. The graph shouldn’t break because of a diagnostic. The diagnostic shouldn’t be suppressed because the graph needs a valid value.
Why Not Result<Type, Diagnostic>?
Because Result is all-or-nothing: you get the type OR the error, never both. In gradual typing, partial information is valuable. If x is Number and y is String and z = x + y is an error, you still want to know that x is Number and y is String. With accumulators, you do.
Running
cargo run --bin ch06-diagnostics
A Note on the <method-call> Diagnostic
If you write a:b() in Lua and run it through the type checker, you’ll see a diagnostic like “unknown variable <method-call>.” That’s not a typo in your code — it’s a placeholder. Our AST doesn’t model method calls (the a:b() syntax), so Chapter 2’s parser replaces them with FieldAccess { object: a, field: "<method-call>" }. When the type checker encounters this field name, it reports “unknown variable” because <method-call> isn’t a real field. The diagnostic is telling you that method syntax isn’t supported, not that you have a typo. A production compiler would add a proper MethodCall variant to the AST; our placeholder keeps things compiling and makes the simplification visible.
Next: Chapter 7: The Language Server — Wire everything into a language server simulation and watch Salsa skip work on edits. This is the payoff for everything you’ve learned.