Null dereferences are one of the most common classes of production bugs, despite being one of the oldest known categories of programming error. Every senior engineer has shipped code with a missing null check at some point. The interesting question is not whether null bugs happen, but why static analysis -- which has targeted this exact problem class for decades -- still misses so many of them.
The answer comes down to what static analysis tools actually check, and what falls outside their detection perimeter. Understanding the gap clarifies what a different approach has to do to close it.
What Standard Null Analysis Checks
Most static analysis tools that target null safety operate on one of two models: dataflow analysis within a single function or method, or type-system enforcement at compile time.
Intra-procedural dataflow analysis tracks whether a variable has been null-checked before being dereferenced within the same scope. This catches simple cases cleanly:
function getUserEmail(userId) {
const user = getUserById(userId);
return user.email; // flagged: user could be null
}
Type-system enforcement -- TypeScript's strict null checks, Kotlin's null safety, Rust's Option type -- makes nullability a compile-time property. If a function accepts a non-nullable type, you cannot pass a nullable value without an explicit unwrap. This is genuinely powerful when the entire codebase is written with strict null safety from the start.
Both approaches share the same structural limitation: they work within the scope of declared types and local control flow. They don't track what happens when data crosses architectural boundaries.
Where Null Bugs Hide That Static Analysis Misses
Cross-Module Contract Changes
Consider a repository where a service layer function returns a User object. For the first year, this function always returned a populated object or threw an exception. Then, to support a new account-deletion feature, the behavior changed: the function now returns null for deleted accounts instead of throwing. The TypeScript signature was updated to User | null.
If the update missed a call site -- which it did, because one of the callers was added three months before the deletion feature and the developer didn't know about it -- that call site now has a null dereference on a code path that never executed during testing. The type error might or might not surface depending on how strict the null checks are configured.
Static analysis operating on the changed file sees the updated signature. It may or may not scan the downstream call sites. Cross-module propagation analysis is expensive, and many tools skip it or make it opt-in precisely because the noise rate from full inter-procedural analysis is too high.
Optional Chaining Masking Deeper Issues
The introduction of optional chaining operators (?. in JavaScript, ? in Kotlin and Swift) changed how developers write null-safe code. Instead of explicit null checks, it's idiomatic to chain:
const city = user?.address?.location?.city;
This is safe. But optional chaining can obscure cases where a null return should actually be an error. If user.address being null represents a data integrity problem -- an account in a state that shouldn't exist -- then silently returning undefined from the optional chain propagates the problem downstream rather than surfacing it. Static analysis sees valid optional chaining. It doesn't evaluate whether the null is expected or anomalous.
Async Race Conditions on Nullable State
In frontend and full-stack JavaScript code, async loading creates windows where data that will eventually be populated is temporarily null. Consider a component that reads from a store after the data has loaded, but that also has a fast path triggered by a user action that can fire before loading completes. The null check is present for the initial render. The null check is absent on the action handler because the developer assumed loading was always complete by then.
Static analysis sees the null check on the render path. It doesn't model the race condition on the action path unless the tool has specific async-aware analysis rules, which most do not.
How Mergegleam Approaches This
Mergegleam's null-check analysis operates at the PR level rather than the file level. When a pull request modifies a function, the analysis looks at the function's callers across the module graph -- not just the file being changed, but the downstream code that calls it. If the PR changes a return type from non-nullable to nullable, the analysis traces the call sites to identify which ones don't handle the new nullable case.
This cross-boundary tracing is what catches the deleted-account scenario above. The modified function is flagged, and the specific call site that lacks a null guard is identified in the review comment with the runtime consequence explained: "this caller at line 87 in user-service.ts assumes a non-null return and will throw a TypeError when called for deleted accounts."
For optional chaining cases, the analysis looks at the context in which the chained access result is used. If the result feeds into a function that requires a non-null value and the chained expression can produce undefined, the potential propagation is flagged regardless of whether the chain itself is syntactically valid.
The result is not perfect null safety -- no automated analysis achieves that across an entire codebase. It is null check coverage that catches the class of inter-module contract bugs that static analysis structured around file or function scope cannot reach. In our early-access data, roughly 12% of flagged null issues were in this cross-module category -- the ones that would have slipped through standard TypeScript strict mode checking.
Those are the ones that reach production.