Go

My experience with Go began a few years ago in a brief attempt to see what the buzz was about. I had waded into the cloud native world running multiple Kubernetes clusters and could not escape the Go ecosystem if I tried. That attempt was short-lived due to oddities with the $GOPATH environment variable and associated workspace as well as a real lack of genuine use cases on my end. I already had everything I needed and could make do with other languages at the time.

Skipping forward several years to the present and I've been working with Go for a few days in order to make use of Tailscale's client library. I have been vaguely aware of improvements in the Go language and ecosystem over that time and know that the folks who work on Tailscale are incredibly knowledgeable Go developers. For all these reasons I found myself once again ready to dive into Go, but this time I've actually taken the plunge.

Setup

The initial experience for Go this time around was dramatically improved and there was no fuss involved. Perhaps I was already saved some discomfort by knowing about $GOPATH, but Go Modules make it an easily resolved annoyance. This initial setup phase was by far the most pleasant part and I've nothing to say other than: well done. Go's out of the box experience should get full (or almost full) stars.

Language

Once I got into actually trying to build something, the many flaws of Go began to surface. My initial reactions were that of surprise and disbelief. However, those reactions aren't particularly helpful to understand why I believe some pieces of the language aren't as good as they can be and it certainly doesn't offer any solutions for improving the language. In this section, I'll attempt to provide more useful feedback and solutions. I will also point out some of the parts that I do truly like about the language, almost all of which I believe other languages would be better for adopting.

Variable Declaration

I find Go's use of the := operator to declare, and assign a value to, a new variable to be the correct decision. Removing syntax bloat while maintaining readability is a difficult battle for any language, but I believe the Walrus Operator is a reasonable solution in this case. However, I do wish that Go would double-down and support type annotations in this form as well. It is awkward to require two lines when one would do:

// Current Go
var x MyType
x = GetSuperType()

// My Preference
x: MyType = GetSuperType()

I understand this is likely due to the preference and frequency of using multiple return values in order to support Go's error handling semantics. Of all the issues I've found, this may be one of the smallest “paper cuts” in the language.

Implicit Return Variables

I've not seen this in any actual code and have been told to never use this feature. In which case I must ask: why is it still in the language?

func getHi() (msg string) {
	msg = "hi"
	return
}

It seems like an interesting thought experiment which I admire and encourage language designers to explore. However this feature is not useful, often being more harmful than convenient. I believe that Go would be better off removing this feature.

Casing-Based Visibility

Using the casing of package contents to determine visibility for consumers is another interesting design decision. At first I was mostly unsure about the idea due to other languages being more flexible with naming conventions. Now, though, I am quite happy with this decision and, while I am still acclimating, I find it makes things clear and consistent when reading code.

The ability to map fields to custom names when transforming to JSON, making certain fields required for validation, and more are all great additions to Go's feature set without requiring much additional work.

type MyStruct struct {
  // Serialize/deserialize to/from a custom key in JSON.
  MyValue string `json:"my_value"`
}

Types

Go is in the difficult position of having to support the type syntax it's committed to, while constantly feeling the pain of worsened readability for doing so. It should be no surprise that the type syntax map[int]string is awkward and more complex types continue to get harder to read and write. Here are a few examples that are unnecessarily confusing.

// Array of one integer
_ := [1]int{0}

// *not* an array, but a Slice of integer with entirely different semantics
_ := []int{0}

// Empty struct
_ := struct{}{}

In some areas Go leans on syntax sugar to help ease some of these issues. I think that the language could do the same in some of these areas, but others seem to be more fundamentally difficult. Unfortunately I don't have a good suggestion for these that wouldn't require a change in the language's grammar.

Go Routines

While there are a few small things to watch out for and mutexes can be awkward, Go Routines dramatically improve the developer experience when developing concurrent applications. So much so that I've been using them! They're overall quite pleasant and reasonable so I have few complaints about them.

Channels

Accompanying Go Routines, channels serve well as event buses. While the syntax can be quirky they've worked as advertised and have been equally as pleasant as Go Routines.

Make

At first it was unclear to me why the make function needs to exist and cannot be replaced with an initializer syntax. After further inspection I understood why the function remains.

_ := make([]int, 10)
_ := make([]int, 10, 20)
_ := make(map[string]int)
_ := make(chan int)
_ := make(chan int, 5)

It seems like Go has backed itself into a corner where there is genuinely no other way to extend its initializers to support any amount of customization. They're all already overloaded to the point of being difficult to read in some cases and adding anything else would certainly do more harm. This is another case where the only solution I see would be a more fundamental change which would come far too late for Go.

Tags

Go's support for type metadata is something that all languages should learn from. The syntax seems to work well in Go to allow for tagging struct fields with additional information that can be used later in unique situations. I cannot stress how useful this feature is and how simple the implementation ends up being. In hindsight, this is a feature that should have been implemented in other languages for years.

Nested Block Comments

Let's lower the stakes again for a moment. While mostly a nitpick, support for nested block comments should be featured in all languages, especially new ones. It is a small thing that goes a long way to improving the developer experience.

Implicit Imports

Go's pattern of globbing together files to put everything on the package scope is an antipattern. This is a strong stance, but one I've learned from having worked with code that did just that. It quickly becomes impossible to know where anything comes from. Instead, imports should be explicit, requiring named members or a namespace to place its contents under. Without explicit imports any amount of Go code quickly becomes difficult for someone else to read.

Go constrains a package's scope to a directory so the damage is not as bad as it could be, but I do not see this as a solution. Rather, a more fundamental redesign of the package import system would be needed to resolve this problem.

Function Signatures

Functions in Go have become unwieldy with 5 separate grouped sections:

Here is an example of a function that could reasonably exist in a Go project.

func (m *MyThing) Act[N int64 | float64](num N) (N, error) {
  // finally do something
}

What's worse is that this example is on the simpler side. With more complex types and more parameters things grow quite quickly. It becomes far more difficult than necessary to read these function signatures. Without altering its syntax, however, there is no better solution.

Single Character Variable Names

This is more of an ecosystem problem in Go than a language one, but deserves a mention. Single character names are often not allowed and actively linted for in other languages due to the cognitive overhead they introduce when trying to read your code. Go developers seem perfectly happy to ignore this advice and use single character names for most things. Other shortened or abbreviated names are no better and should be actively discouraged. Go will not let you compile your application if you have an unused variable, but is happy to compile if all of your variables are unreadable. It seems like there is more that can be done to ensure Go programs are maintainable over time in addition to being quick to develop.

Packages

Time Format

Go's time package made the decision to use a pre-defined date as templating values for parsing and formatting. This change is no more arbitrary than strftime and only seeks to upset the law of least surprise by providing yet another flawed solution.

Go's time package uses the date Jan 2 03:04:05 PM 2006 MST as template parts in order to format or parse other dates. These values were selected by counting upwards: 01 02 03:04:05 06 MST (MST is GMT-7). Ideally this would disambiguate which numbers correspond to which values. In practice it is just as confusing (or more) than strftime. Immediately the first value being the Month throws off developers outside the US as well as a good chunk of US developers who use other formats (typically ISO-8601). Why should we prefer time.Parse("01, 2006", myDate) over something like time.Parse("mm, yyyy", myDate)? I believe that we shouldn't.

Instead, either a syntax consistent with other languages should be used or the Go alternative should remove its arbitrariness altogether and parse templates in another way like the following.

time.Parse("<padded-month-number>, <full-year-number>")

This problem is more confusing given Go's fmt helpers which do have similar functionality to other languages and make use of template tokens like %s, %d, etc. This is not a foreign concept for Go and I think that strftime formatted templates would feel right at home in the language.

Closing Thoughts

In my experience with Go I've seen a pattern begin to emerge . You've likely already spotted it as well: many of these issues would require fundamental changes to Go in order to fix. Down to the syntax, Go suffers from a number of consequences due to its design decisions. Almost all of these problems seem to be self-inflicted and unrecoverable without committing to a major release which is incompatible with the entire current ecosystem. Some issues may be solved or given band-aid solutions, but the majority seem to be here to stay. Even of the issues that I believe could be fixed there seems to be the consensus that these aren't problems and Go is designed perfectly. Without a large amount of effort and willingness to change, I don't see Go evolving into the language that it could be. The language provides several unique features and possibilities, but they're currently squandered by consequences of early design decisions.

Finally, I know that creating a language is hard. Designing it to be perfect is near impossible and actually hitting that mark is even more difficult. I want Go to be better, I think it has a lot of potential and I can see myself falling in love with the language if it continued to improve. With its large following the language is in a unique place to effect positive change for many developers, new and old.


P.S. On the off-chance that someone reading this works on Go, then I'd like you to know that I've appreciated the work you've done. And I hope that you're given the opportunity and support to make the language even better.