I recently started learning Go. It has been advertised to me as a great programming language to create web services with a very good built-in concurrency library.
While going through the tutorials, I noticed the way Go makes developers handle errors and how different it is compared to Javascript. In this post, I want to go through this difference and will conclude by stating my preference.
Javascript is not typed. Functions can return whatever they want: numbers, booleans, functions, strings, etc. It's up to developers to document functions properly and/or use JSDoc, Typescript and alike. Let's look at a very simple example together.
function main() {
const stringified = JSON.stringify({ aBigInt: 2n });
console.log(stringified);
}
main();
The code above will result in an exception that makes the program crash:
const stringified = JSON.stringify({ aBigInt: 2n });
^
TypeError: Do not know how to serialize a BigInt
at JSON.stringify (<anonymous>)
at main (/home/kmerckx/dev/blog/index.js:2:28)
at Object.<anonymous> (/home/kmerckx/dev/blog/index.js:6:1)
at Module._compile (node:internal/modules/cjs/loader:1565:14)
at Object..js (node:internal/modules/cjs/loader:1708:10)
at Module.load (node:internal/modules/cjs/loader:1318:32)
at Function._load (node:internal/modules/cjs/loader:1128:12)
at TracingChannel.traceSync (node:diagnostics_channel:322:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:219:24)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:170:5)
If that piece of code that serializes an object would be running on a HTTP server, it would likely crash it. To get around that issue, developers (or the framework they use) have to wrap the call to JSON.stringify
in a try...catch
. A modified version of the script could be:
function main() {
try {
const stringified = JSON.stringify({ aBigInt: 2n });
console.log(stringified);
} catch (error) {
console.error('We got an error here, can not print the stringified object', error);
}
}
main();
The program doesn't crash and exits with code 0
. What we are experiencing here is a consequence of JavaScript error handling: errors can be thrown and it's up to you to catch them. Within a simple program, it's not a big deal: you call standard ECMAScript functions, put some try catch around them, done. As soon as your program is more complex, those standard functions end up being called pretty deep in the call stack. Every level of this stack can receive errors that bubble up from the levels below, making the error handling very hard to figure out.
You would think that Typescript could help in this endeavor but it doesn't. The error that you catch is of type unknown
.
A language like Go has a built-in error type and lets developers handle errors as values. It forces you to handle errors. You can not ignore them. In the following example, you can see that in action:
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
I like the simplicity of JavaScript but I came to realize that the error handling was one of the major flaws of its design: a simple scripting language that lets you walk very fast on the happy path. Unfortunately, web services are rarely made of happy paths. You might have to deal with malformed data, network issues, etc.
I work a lot with JavaScript and while I think it's not meant for backend development, I also think there is a lot of value in using it with Typescript in a full-stack team to rapidly create products. In the second part of this post, I will present an attempt to make JavaScript error handling a lot nicer. It does have its limits and I will also present them.
I really like Go's approach and the solution I implemented is inspired from it. It is especially powerful when used with Typescript.
import { makeSuccess, makeFailure, SafeResult } from '@km/saferjs'; // this is not published on NPM!!
function someSafeFunction(): SafeResult<number, SomeCustomError | TypeError> {
try {
return makeSuccess(
... // your logic here
);
// you can also return a makeFailure(...)
} catch(error) {
// maybe it's a TypeError: error instanceof TypeError
return makeFailure(error);
// error handling goes here
return makeFailure(new SomeCustomErrorClass());
}
}
const { result, error } = someFunction();
// at this point Typescript knows that result is either null or a number
// and error is either null or one of the type SomeCustomError, TypeError
if (error) {
// handle the error
// at this point, you can early-throw or -return
// Typescript will understand that after this if-block, result is defined.
} else {
// result is a number
}
The type inference is perfectly done by Typescript, giving your a lot more safety at transpile time. I also came up with two helper functions isFailure
and isSuccess
that can be used as follows:
const someResult = someFunction();
if (isFailure(someResut)) {
// handle error
}
if (isSuccess(someResult)) {
// happy days!
}
After implementing that approach and adopting it in the code I write, I realized it suffers on major drawback: none of the JS standard functions follow this patter and they all throw errors. Making the code in my projects be perfectly safe to use would mean that I have to wrap all standard function calls. Tedious.
I can make Nodejs backend very safe with such an approach but I came to realize that, if it's not built-in the foundation of the language, it's never going to be a good developer experience. JavaScript is just not meant for backend.