Here’s my latest article, about how to use HasCallStack
for error messages in embedded domain specific languages.
This feels unnatural.
The constant
function should have a “position” argument, and for this particular case the position is getCallstack
. Exceptions are not needed here at all (though I could go as far as to say they’re not needed ever when dealing with expected errors, but that’s just my view on things).
Also I would expect the size check on Data
to be performed in a separate stage instead of being mushed together with all other future errors.
Sure, all those suggestions are fine. This is just a small sketch to present the idea of even carrying around CallStack
s at all in EDSLs.
Quite a nice use case for cheap error locations! Works well for shallow embeddings.
Note that using error
to throw an exception is not a particularly safe thing to do, because GHC’s optimisations are free to mask it with another imprecise error or a loop.
The latter version where you do throw
should fare much better, because anything thrown through raiseIO#
is considered a precise exception and will inhibit strictness analysis, for example.
What’s more, I think I would prefer it if errors where part of Assembly
, i.e.
data Error = ...
data Assembly a
= MkAssembly (forall es. Stream Constant es -> Stream Error es -> Eff es a)
and then you yield errors in constant
or elsewhere. This has the advantage that you do not always need to capture the call stack, which might be expensive (if only in terms of memory residency).
Furthermore, I think it’s likely that one would want to differentiate between fatal errors (which halt the assembly pipeline immediately) and resumable errors (such as your “Wrong size constant”). Resumable errors can be yielded, but fatal errors would need to throw.
Yeah, agreed that imprecise exceptions are not the correct approach. To clarify for those less familiar with the area, “not particularly safe” here doesn’t mean unsafe like unsafeCoerce
or unsafePerformIO
, but it does mean it can be uncertain which exception is raised within pure code, for example error "foo" + error "bar"
is not guaranteed to throw one or the other.
I’ve adopted this technique in the latest version of my cauldron dependency injection library.
One problem with the previous version was that, because wiring was checked at runtime, I didn’t have the source locations for wiring errors like I would have if wiring were stately checked by the compiler. It would print that a dependency required by the Bar
constructor was missing, but not point to the source location of Bar
.
Now, after storing the CallStack
s, I can give nicer errors like
This constructor for a value of type G:
CallStack (from HasCallStack):
val, called at app/Main.hs:201:26 in cauldron-0.4.0.0-inplace-cauldron-example-wiring:Main
is missing the following dependencies:
- E
I’ve also learned how the function withFrozenCallStack
might be useful: for the purpose of showing error locations, you want to store the CallStack
only up to when it “enters” the library code, but not accumulate frames for internal calls within your library, which are irrelevant to the user. So in these cases it’s better to freeze the call stack. For example, if I remove a withFrozenCallStack
in one of the functions and trigger an error, I get:
This constructor for a value of type G: CallStack (from HasCallStack):
val', called at lib/Cauldron.hs:1040:9 in cauldron-0.6.0.0-inplace:Cauldron
val, called at app/Main.hs:204:18 in cauldron-0.6.0.0-inplace-cauldron-example-wiring:Main
is missing the following dependencies:
- Foo
where the line
val', called at lib/Cauldron.hs:1040:9 in cauldron-0.6.0.0-inplace:Cauldron
is an internal call within the library and therefore noise.