Why don't error messages name offending variables/function returns?

While I’m generally loving the experience of learning and using Julia, a frequent source of frustration is parsing its error messages. Even when I understand the content of the error message and I’m given the offending line, it can still be hard to actually nail down the source of the error because I’m not told what within the line actually caused the offense. Ideally a type conversion error would name the variable that had the wrong type. An array bounds error would name the array I was trying to access incorrectly, and so on.

As an example, I once wracked my brain for half a day trying to figure out why a line of arithmetic was trying to convert an object of type Nothing to Float64, but with 6 variables in the line and no guidance I ended up just using printing typeof() statements until I figured out that I accidentally had a function call return Nothing instead of the intended result. If it had simply said Can't convert my_function(args) of return type Nothing to Float64, I would immediately have known that the issue was with the function result.

Perhaps there’s a limitation of the language here that I’m fundamentally misunderstanding, but I guess just don’t understand why, if Julia can trace errors through a call stack to the specific line of source code, it can’t go one step further and just name the variable or function that’s the problem. If there’s a more nuanced explanation here, please do enlighten me.

The conversion methods that throw the errors do not know anything about the call sites to provide the information you want, and the stack trace does not bother to infer the types of variables or other subexpressions in the offending lines. It’s possible for the call site to throw the error instead, but you’d have to know which variables could have the wrong types to begin with and throw the errors yourself. You can instead use type inference reflection e.g. @code_warntype to spot statically knowable types of subexpressions, though it may not stand out. For example, you’d have to notice the possible +(::Nothing, Float64) at the end because @code_warntype isn’t concerned about thrown errors and missing methods.

julia> foo(x::Float64) = x + ifelse(rand((true, false)), 1.0, nothing)
foo (generic function with 1 method)

julia> @code_warntype foo(1.2)
MethodInstance for foo(::Float64)
  from foo(x::Float64) @ Main REPL[14]:1
Arguments
  #self#::Core.Const(Main.foo)
  x::Float64
Body::Float64
1 ─ %1 = Main.:+::Core.Const(+)
│   %2 = Main.ifelse::Core.Const(ifelse)
│   %3 = Main.rand::Core.Const(rand)
│   %4 = Core.tuple(true, false)::Core.Const((true, false))
│   %5 = (%3)(%4)::Bool
│   %6 = Main.nothing::Core.Const(nothing)
│   %7 = (%2)(%5, 1.0, %6)::Union{Nothing, Float64}
│   %8 = (%1)(x, %7)::Float64
└──      return %8

It is however possible for reflection to sound alarms about statically known errors:

julia> using JET

julia> @report_opt foo(1.2) # spots statically unknown types
No errors detected


julia> @report_call foo(1.2) # looks for more errors from statically known types
═════ 1 possible error found ═════
┌ foo(x::Float64) @ Main ./REPL[14]:1
│ no matching method found `+(::Float64, ::Nothing)` (1/2 union split): (x::Float64 + ifelse(rand(tuple(true, false)::Tuple{Bool, Bool})::Bool, 1.0, nothing)::Union{Nothing, Float64})
└────────────────────

You might be expecting something more obvious like ahead-of-time compiler errors for statically typed languages. Dynamically typed languages with REPLs don’t naturally do that, types don’t need to be known at compile-time, and type errors are allowed to be thrown at runtime like other errors. Although technically possible, runtime errors won’t rerun type inference at every method just to highlight parts of lines.

Try JETLS.jl. It will warn you if variables can be nothing. If you start using it, it will probably give way too many warnings, but if you fix them one by one (or suppress invalid warnings), you get a much better experience in the end. AI can help to fix JETLS warnings, too.

I was actually asking about detailed runtime error info. Basically, say I have an error like this:

julia> a = zeros(Int64, 5)
5-element Vector{Int64}:
 0
 0
 0
 0
 0

julia> b = "silly string"
"silly string"

julia> for i in eachindex(a)
           a[i] = b
       end
ERROR: MethodError: Cannot `convert` an object of type String to an object of type Int64
The function `convert` exists, but no method is defined for this combination of argument types.

Why can’t the MethodError say something like:
object 'b' has type String, which cannot be converted to type Int64 in the assignment statement at REPL[3]:2.

That carries the same info (no method exists to convert a string to an integer) but would also specifically name the variable that’s causing the issue. Likewise, given the following:

julia> a = zeros(Int64, 5);

julia> b = ones(Int64, 50);

julia> for i in 1:50
           a[i] = b[i]
       end
ERROR: BoundsError: attempt to access 5-element Vector{Int64} at index [6]

Why can’t the BoundsError say attempt to access 5-element Vector{Int64} 'a' at index [6]? Then I would immediately know that the array access issue was in a, not b. Perhaps that’s what you were referring to when you said that

but I guess I don’t understand what you mean if that’s the case. Surely if it a) knows the objects associated with given variable names in the source code, b) knows the type of those objects at runtime, and c) can throw errors due to invalid operations on those objects, then it should be trivial to print the name(s) of the object(s) that caused the error, right?

I apologize, as I’m genuinely not trying to be rude or dense, but I really don’t understand why this is difficult to accomplish.

It doesn’t. The call f(a, b) does not inform the method of f that the input objects were assigned to a and b in the call scope, so whatever error f throws cannot name those variables. In many cases there wouldn’t be variables to name, like f(arr[42], 0). If the error is thrown by a nested function call, then the subexpressions would have to be traced across calls.

We’d know the subexpressions after some investigation, and hypothetically the program could know it too if it performed type inference or other static analyses across the stack trace when errors are thrown, but that kind of overhead is undesirable at runtime, especially if it could routinely happen in try-catch. It’s better to reserve static analyses for the statically knowable errors, no need to execute a possibly long program (though unit testing is still recommended to catch things static analysis can’t); I don’t know of anything that traces variable names like you want, though.

Thanks for explaining in more detail—I really do appreciate it. If a program halts when an error is thrown, however, why does the overhead of performing type inference across the stack matter? I get that this would be a problem if it were occurring at every function call in advance of an error being thrown, but if it happened after the error (like an implicit finally), then presumably the cost would be comparatively low.

Ideally we should try to show the offending line start + column start + column end + line end, not just line start.

And then display the offending lines, + 4 lines context, with the offending substring highlighted.

I fear doing that would be quite a bit of work, because afaiu a lot of our debug info is on the “filename + linenumber only” level.

That is a problem that julia shares with a lot of other languages. For example, java tooling is abysmal with that respect (oh, nullpointer exception on line xyz. Yeah, which of these? Am I supposed to reformat my code because you can’t include column numbers? In an age where I can’t buy 4:3 monitors any longer?)

I don’t think we at julia have a real excuse for that issue. Java at least has the excuse of “in the 90’s, paying for column number in debug info was expensive”.

I was hoping to get away with a sentence, but to dive deeper, you are making many assumptions here that makes your proposal convenient for your particular half-day issue and type errors in similarly narrow situations, not generally.

The vast majority of thrown errors aren’t elucidated by type inference in any way, e.g. 1÷0 throwing DivideError. Most convert problems throw MethodError: Cannot `convert` , but that’s not nearly the only one. Conditional converts throw different errors, like convert(Int, 1.2) throwing InexactError. In the earlier foo(1.2) example, the type error doesn’t occur at convert, so we get a different MethodError message. Assuming we manage to figure out the exact subset of errors worth doing type inference for, it may fail anyway because a dynamic language does not demand these types be statically knowable.

So let’s further assume we have a different analysis, something along the lines of tracking the runtime values’ subexpressions and types across calls. There are many scenarios where throwing an error doesn’t halt the program; catch obviously, but also non-waited Tasks. If I want to run a 3 hour program and take a generous lunch break for example, I’m very likely to let the program handle and perhaps log any errors that occur e.g. a failed HTTP request. Even if the analysis appears fast, a blink of an eye is massive overhead in computation that shouldn’t be repeated for arbitrarily many runtime errors. If these errors are actual bugs instead of informative failures, I don’t want to run a 3 hour program just to find them, especially if the program keeps halting at points I haven’t managed to handle and needs to be rerun from scratch to reach the next bug. For good reason, static analyses occur ahead of (execution) time on our schedule. As mentioned earlier, this comes more naturally to AOT-compiled statically typed languages.

We also don’t want to only run static analyses after a runtime error because there are plenty of potential bugs that don’t inform the program to throw an error. A minor change to your program could easily run to completion, only to return a result riddled with missing. 1/0 == Inf in the floating point standard, so no DivideError. There may not be static analyses readily available to catch these even when statically known, so proactive handling goes a long way e.g. ismissing, iszero, skipmissing, filter.

Could start here for more information on narrowing down the location of erroring calls in source: finer-grained location info for error messages, strack traces: line number, column number range · Issue #53090 · JuliaLang/julia. I don’t think it would often tell us where bugs are by itself, but narrowing things down from the start makes it easier.

I think there’s a short answer to the question here, that is we need JuliaLowering.jl to land to make this possible. That’s currently in-flight, i.e. not possible today but hopefully soon.

Thanks again for taking the time to explain things so thoroughly. I clearly didn’t have a correct picture of how things work under the hood. I’m still adjusting to the fact that Julia isn’t simply “a compiled language with a REPL”, even though it sometimes feels that way.

The reference implementation of Julia is a compiled language (to native, I’m excluding bytecode interpreters) with a REPL, it’s just that “compiled language” strictly means a lot less than its colloquial categorization of mainstream languages. There are many more facets to language design, and while Julia does share some characteristics with mainstream AOT-compiled languages to make compilation worth doing at all, it’s still a dynamic language. Allowing more runtime errors and making static analysis harder comes with that. A few mainstream AOT-compiled languages do have REPLs that maintain a persistent program context, it’s just less common to avoid or work around conflicting characteristics, and some crowds don’t demand a REPL workflow anyway.