I wish Go were a Better Programming Language
I recently wrote and deployed my first Go application.
Over a 30 year career, I’ve written applications in C, C++, Visual Basic, Objective-C, Typescript, Python, Tcl, PHP, Java, C#, Rust, and probably some other languages I’ve forgotten.
I just wrote and deployed my first Go application. It’s a little web service. There’s not much to it: about 1000 lines of code that translate incoming HTTP requests into arguments for a command line tool, then pipe the output of the command line tool into HTTP responses. This is a perfect fit for Go, and I don’t regret choosing it. However, the experience made me wish Go were a better programming language.
I’ll start with the positive and proceed to the negative.
The Positive
Go’s standard library is fantastic. With other languages like Typescript and Python, my apps have a few direct dependencies which in turn bring in hundreds of indirect dependencies. I probably spend about 10% of my coding time upgrading dependencies because a security vulnerability was discovered in an older version or the author decided to drop support for version 2 and everyone has to migrate to version 3. And I have never once run npm audit
and seen a clean result.
By contrast, Go’s standard library provided a web server that supports HTTP2. That meant drastically fewer dependencies than same app built with Rust or Typescript. Also, I searched for Go’s vulnerability database and found that security vulnerabilities in the standard library are rare, and usually only deny service. These are way less scary than the vulnerabilities I routinely see in JavaScript dependencies.
Templ is a dream. Why don’t all templating libraries work this way? I don’t need layouts, bodies, blocks, and the other confusing abstractions I see in other templating solutions. I’m looking at you, ASP.NET. All I need are type-safe functions and data. Templ succeeds in not making the solution more complicated than the problem.
I love Goroutines. I’ve been a fan of light-weight threads since the 90s. They’re the best way to implement parallel processing. I’m mystified that languages born in the 90s and later like Java, Rust, and C# haven’t adopted them. Async Rust is a mess. Async Javascript is error prone. I’ve written Typescript code that forgot to await
an asynchronous function, and the result was a long debugging session for me. With Goroutines, the flow of the code matches the flow of the logic in my head, and there’s no performance burden of an operating system thread.
Two more benefits of Go that were critical for my application were fast cold start times and low memory usage. Go lived up to my expectations on all these points.
The Surprisingly Neutral
I’m surprised that I didn’t hate Go’s (lack of) error handling. Yes, roughly 1 of every 5 lines of my code is if nil != err {
. However, delivering an accurate, useful error message to the user is always difficult, even with exceptions or Rust’s Result
type.
Also, when I look at this code again in a few months, I suspect I’ll appreciate the if nil != err {
statements in ways I don’t today. Error propagation and handling is difficult to understand in unfamiliar code written in a language that throws exceptions.
The Negative
During development, my application misbehaved and crashed in ways that would have been caught at compile time with other programming languages, specifically Typescript, Rust, and C#. I probably made other mistakes that I haven’t discovered yet and will eventually be discovered by my users. That’s a bad experience for them and me.
I was burned by code-as-strings. Code-as-strings are string literals in the source code that actually behave as code.
I followed the official Go documentation and wrote this line of code: mux.HandleFunc(“GET /form”, formHandler)
I was very surprised when a fetch for /form
didn’t execute the formHandler
. Instead, it executed the default handler, the same as fetching /
. How could this be? After about an hour of debugging and searching, I learned that I had installed Go 1.21, but I was reading documentation for Go 1.23. HandleFunc()
only started started supporting method specifiers (the GET
in my string) in version 1.22.
I blame myself for mismatching the versions of the go compiler and the documentation. I blame the Go standard library authors for not catching my mistake at compile time. It would have been easy to catch it at compile time. Instead of enhancing the string passed to HandleFunc()
in version 1.22, they could have added a new function like this: mux.HandleMethodFunc(“GET”, “/form”, formHandler)
Then my error would have been caught at compile time, because the 1.21 compiler would have complained that HandleMethodFunc()
doesn’t exist.
It’s nearly impossible for the compiler to catch mistakes in code-as-strings, and they’re used lots of places in Go, for example in serializing structs to Json:
type LogEntry struct {
Severity string `json:"severity"`
Message string `json:"message"`
}
What other bugs have code-as-strings hidden from me until exactly the wrong conditions arise at runtime?
I dereferenced nil and Go panicked. Tony Hoare called null
his billion-dollar mistake. I guess the Go language designers wanted to one-up Mr. Hoare and invented nil
. Not only did my code dereference nil
on multiple occasions, I didn’t even understand when a value could be nil
. Take this function, for example:
func IsInternal(e HttpError) bool {
...
Can e
be nil
? I can’t tell unless I go look at the definition of HttpError
. If it’s a struct
, then no, it can’t be nil
. If it’s an interface, then yes, it can be nil
.
If variable p
is nil
, can I call p.foo()
? Maybe. It might panic, it might not. The only way to know is to check the definition of foo().
And as code is maintained, someone may change foo()
’s definition so it doesn’t panic on nil
, and leave behind a hundred call sites all checking if p == nil {
. The ambiguity over whether a variable can be nil
probably triggers more accidents than a broken traffic light.
I also made mistakes with nil
that didn’t panic. I realized I had written a Go anti-pattern only after watching Francesc Compoy’s talk on understanding nil for the second time. I had written code like this:
func OhNo() error {
var httperr *http_error = nil
return httperr
}
func TestOhNo(t *testing.T) {
if OhNo() == nil {
fmt.Println(("It's nil."))
} else {
fmt.Println("No it isn't.")
}
}
I was surprised when TestOhNo()
printed “No it isn’t.” This is a language quirk that Go programmers learn quickly, I hope. I expect to make mistakes like this when learning a new language. But I blame Go for not catching this mistake at compile time. This bug was lurking in my code and I got lucky and watched Mr. Compoy’s talk and figured out my mistake. Otherwise, I would have only discovered it when a user reported my application behaving incorrectly.
By the way, go vet
caught none of the issues I described above.
Conclusion
I make too many mistakes to make Go my first-choice programming language. I have come to depend on the Rust and Typescript compilers catching my mistakes. My users unknowingly appreciate it because they never see those mistakes. And one benefit I enjoy is writing fewer tests: no need to write a test for when a parameter is null
if the parameter can never be null
.
Even with this experience, there are still some, but not many applications for which I would choose Go. For nearly every programming language, I can imagine an application where it would be the ideal language to use.
But in the end, I feel disappointed by Go. The Go runtime is excellent. Go is so promising. I just wish Go were a better programming language. I wish it caught more mistakes at compile time instead of letting my users discover them at run time. Other languages do it; I expect no less from my next language.