It didn’t take me long to realize I simply hate working with
Option type as a replacement of exceptions — instead of simply writing the code I have to babysit each possible failing function and write conditions for each optional value (I am not the only one in pain). So I read again about Java, C#, Haskell, OCaml and Rust (did you notice Rust team introduced subtle flaw in design — in Haskell and OCaml the correct outcome of the function is the right outcome. In Rust it is on the left, so the right answer is the erroneous answer). I can sum this up as follows:
- C# approach is a dead end because it leads to duplicating functions — dreadful
- Java annotated functions with exceptions that can be thrown and passed was a mistake, it only takes few minutes of writing the code to feel tired. And for unchecked exceptions it shares the same problem as C#,
- Haskell looked promising until I saw the real code tackling the errors — so partially I would be in camp I want to escape from already (Skila), and partially in heavy function annotating camp which I dislike since my Java experience. Also I am sceptical how smooth this solution is when it comes to inheritance and when you read “Error handling” chapter (from Real World Haskell by Bryan O’Sullivan, John Goerzen, Don Stewart) you might get the impression that something is fishy, if one feature kills another feature and you need to jump extra hoops to use both.
I have wrong design (more in artistic sense) and three approaches which, well, I don’t like.
I could agree with the sentence “if you don’t have anything right to say, or anything that moves the art forward, then you’d better just be completely silent and neutral, as opposed to trying to lay out a framework” (from interview with Anders Hejlsberg) but not in this case — working on and implementing the language which is broken from start is no fun, and has no value at all. I must make a second, third — fourth if necessary — try to find out the solution. After all it has to be somewhere.
Skila will have exceptions but they will be thrown usually by code injected by compiler. All errors in computations should be signalled by:
- optional value — if it is likely that user will handle the error. It is counterpart of function that requires its outcome to be read. As an example consider
String.indexOf — not finding character in string could lead to an error, but it is rather not an error by itself,
Error type — here the situation is reversed, it is more likely that the “client” would treat an error like a serious computation failure and pass it for handling to some upper function, it is counterpart of the function that allows its outcome to be ignored. Every unhandled error is automatically converted to exception.
Error type will be subtype of any other type, so returning an
Error instead of “real” value does not need to (and cannot?) be annotated in function signature. In other words every function can fail.
You can convert optional value to exception (this is already done), you can convert error to exception or to optional value. The beauty I foresee is present in several aspects:
- it is transparent to inheritance,
- it should not clash with any other feature,
- single function will be enough,
- the flow of the errors is layered,
- no extra code necessary — you want to cope with errors, you do it, you don‘t want to — simply behave naturally (as with exceptions).
What is layered flow of errors? By default, the function you talk directly to could give you an error, but all third party functions involved in computation can result in exception (because every unhandled error becomes an exception). You could depict such propagation in default flow:
deep function ⟶ nearby function ⟶ your function
(error) | |
| (error) |
| (exception) |
| | (exception)
In the first line you see
deep function returns an error. The error is passed to the
nearby function (line 2), since it is incoming error and it is unhandled it becomes an exception (line 3), it is then “passed” to
your function. In more real life perspective, when you call for example
dict[key] (nearby function) for non existing key you will get error and you can handle the error right away. But if the failure is caused because there is a some logging (deep function) involved, and that logging failed, you will get an exception, not an error.
Again, this is the default flow — every function can handle the values it gets and convert them to whatever necessary.
Entire mechanism is very similar to passing
null in C# — in Skila it will be done on purpose, as a bait. But there is difference — in C# you can pass
null without triggering exception, in Skila even passing has to be checked. This is for easier finding the source of the error and also to consistently process all the functions including those which work for their side effects (all
Void functions are prime examples). So performance can be a victim here.