A story of exception encoding in BuckleScript
We just recently made some significant improvements with our new exception encoding and we find it so exciting that we want to highlight the changes and explain a little bit how exceptions work when compiling to JS.
The new encoding allows us to provide proper, clear stacktrace information whenever a Reason/OCaml exception is thrown. This is particularly important when you have some code running in production that needs to collect those stacktrace for diagnostics.
What's the difference?
exception My_exception { x : int};
let loop = () => {
for (i in 0 to 100) {
if (i == 10) {
raise (My_exception { x : i})
};
};
};
loop ();
When we compile and run this piece of code with the old exception encoding, this is what we'd get:
exn_demo$node src/exn_demo.bs.js
/Users/hongbozhang/git/exn_demo/src/exn_demo.bs.js:11
throw [
^
[ [ 'Exn_demo.My_exception', 1, tag: 248 ], 10 ]
With our new improvements, we now get way better results:
bucklescript$node jscomp/test/exn_demo.js
/Users/hongbozhang/git/bucklescript/jscomp/test/exn_demo.js:10
throw {
^
{
RE_EXN_ID: 'Exn_demo.My_exception/1',
x: 10,
Error: Error
at loop (/Users/hongbozhang/git/bucklescript/jscomp/test/exn_demo.js:13:20)
at Object.<anonymous> (/Users/hongbozhang/git/bucklescript/jscomp/test/exn_demo.js:21:1)
at ...
}
That's basically it! Furthermore in this post, we want to give you some insights on how the data representation of exceptions looks like, and how it has been changed to expose useful stacktraces.
Why it is tricky to preserve stack-traces in ReasonML exceptions
Whenever you are using a Reason / OCaml exception (a so called "native exception"), you are actually using a data structure which is not the same as a JS runtime exception. That means that each exception representation invokes a different stacktrace handling mechanism:
In JS, the stacktrace is collected immediately when an Error object is created / thrown, while in native Reason / OCaml, such data is not attached to the exception object at all (you can't just access e.stack
to retrieve the stacktrace). This is because collecting the stacktrace in a native environment highly depends on the runtime support (e.g. if a flag was provided to attach the stacktrace data).
Our goal was to provide a way to get the same stacktrace for native exceptions as you would with JS exceptions. This is all part of our on-going work to plan and implement the optimal encoding for all the different ReasonML data types for the JS runtime (just like with our previous changes to the bool
, unit
and records
representation as well).
What's the classical ReasonML exception encoding?
In ReasonML, an exception is basically structured data. Let's have a look at the two exception definitions below:
exception A of { x : int , y : string}
exception B
exception A
is encoded as an array of 3 slots. The first slot is a block by itself (called an identity block), while the second slot is for field x and the third slot for field y.
exception B
is just the identity block.
The identity block is an array of 2 slots. The first slot is a string like "B", while the second slot is a unique integer. In more detail, the native array will also have a magic tag 248 attached which is not relevant for our purposes though.
What's the new exception encoding?
We had to simplify and unify the encoding for the different exception cases to make it possible to compile exceptions into an object instead of an array. Let's take a look at the two exception values below for example:
A ({ x : 1, y : "x"}
B
The two values will be compiled into
{RE_EXN_ID : "A/uuid", x : 1, y : "x" }
{RE_EXN_ID : "B/uuid"}
As you can see, all exceptions (no matter with or without payload) share the same encoding.
What will happen when you raise an exception?
raise (A {x : 1 , y : "x"})
It generates following JS:
throw {RE_EXN_ID: "A/uuid", x : 1 , y : "x", Error : new Error ()}
The output above shows that we are now able to attach the stacktrace as an Error
attribute very easily, since every exception is now an object instead of an array. Really cool!
It's important to note that a stacktrace will only be attached when you raise an exception. In other words, the stacktrace will not be attached just by creating an exception (which is different to JS'es new Error()
behavior).
What does that mean for JS interop?
Note that in the JS world, users can pretty much throw any value they want. It is even totally valid to throw undefined
. In ReasonML, when you try to catch an exception, the compiler will convert any arbitrary value to a ReasonML exception behind the scene:
- If it is already a ReasonML exception, then the conversion will be a no-op (no runtime cost
- Otherwise it will be wrapped as a
Js.Exn.Error obj
Here is an example on how you'd access the exception value within a Reason try
expression:
try (someJSFunctionThrowing()) {
| Not_found => .. // catch reasonml exception 1
| Invalid_argument => // catch reasonml exception 2
| Js.Exn.Error (obj) => ... // catch js exception
}
The obj
value in the Js.Exn.Error
branch is an opaque type to maintain type soundness, so if you need to interact with this value, you need to classify it into a concrete type first.
Caveat
Please note that it's not allowed to rely on the key name of
RE_EXN_ID
. It's an implementation detail which will probably be changed into a symbol in the future.Don't over-use exeptions, remember exception should only be used in exceptional cases like division by zero. Whenever you try to express erroneous results, use the
result
oroption
type instead.
Bonus
Now with our new exception encoding in place, a hidden feature called extensible variant suddenly got way more interesting as well. Practically speaking, native exceptions are actually a special form of an extensible variant, so both are benefiting from the same representation changes!
Happy hacking and we would like your feedback!