A TypeScript Perspective on Go: the 2021 Advent of Code

It'll surprise no one to hear that TypeScript is my favorite programming language. But I do still enjoy dabbling in other languages. It's a great way to get perspective on what makes TypeScript unique, and how other language designers are thinking about the same problems.

My favorite way to learn a new language is through the annual Advent of Code (AoC). AoC runs every year from Dec 1-25. Every day unlocks a new puzzle with two parts which build on each other. Lots of people do these puzzles (over 100,000 completed day 1 this year) and post their solutions on r/adventofcode. In 2019 I did it in Python (not a new language for me) and in 2020 I did it in Rust (which was a new language). This year, I did it in Go (aka Golang).

This post has three parts:

  • My impressions of Go (coming from a TypeScript perspective)
  • My impressions of this year's Advent of Code
  • Notes and links for each day

My Impressions of Go

I first used Go when I was working at Google in 2011. C++ was my main language at the time, so Go wasn't such a big shift. I remember finding it quite confusing until I really "got" the idea of duck typing: the consumer of an interface defines what it needs and the producer doesn't need to declare that it implements this interface. I didn't have much use for Go in my work or personal projects, so I mostly forgot about it.

Some parts of Go's design that seemed strange in 2011 have aged quite well: automatic formatting of source code is very standard now, and I certainly didn't mind Go complaining about unused symbols. One thing that I found confusing in 2011 and still found confusing today: Go's insistence on where you put your source code on disk. I usually put all my GitHub repos in ~/github/reponame. But Go wants them under $GOROOT, which is ~/go (or ~/gotip) on my system. This made my setup experience a bit frustrating, but once I gave up and put my code in $GOROOT everything worked OK and I moved on.

Last year my pattern was to solve the puzzle, then look at how more experienced Rust developers solved it on r/adventofcode. I almost invariably learned something from this. That didn't work this year. In Go, the straightforward approach is usually the canonical one. There are just fewer surprises. Go takes pride in being a "boring" language. I learned more about Go from having @derat review my code and make more specific suggestions.

I'm aware that the Advent of Code doesn't particularly play to Go's strengths: there's no concurrency, for example. But I still found it to be a good way to force myself to learn the basics of the language and use it to solve real problems.

Here are a few differences between Go and TypeScript that stood out to me.

Implicit copies

I'm not aware of any situation in which JavaScript makes a copy of an object without your asking it to. In Go, however, this happens all the time. Building a mental model for when these copies happens is always relevant for performance, and sometimes relevant for correctness.

In TypeScript, we can create a Student type with a name and age, and write a function to make a student age by a year:

interface Student {
name: string;
age: number;
}

function AgeAYear(s: Student) {
s.age += 1
}

const bobby: Student = { name: 'Bobby', age: 12 };
AgeAYear(bobby);
console.log(bobby.age); // Prints 13

In JavaScript, objects are always passed by reference. There's only one Student floating around and AgeAYear modifies it, which is why this code logs 13. (Are primitive types like string are passed by value or by reference? It's irrelevant because they're immutable.)

In Go, something very different happens:

type Student struct {
name string
age int
}

func AgeAYear(s Student) {
s.age += 1
fmt.Printf("Age is %d\n", s.age) // prints 13
}

func main() {
bobby := Student{"Bobby", 12}
AgeAYear(bobby)
fmt.Printf("Bobby's age is %d\n", bobby.age) // prints 12
}

Go passes objects by value, not by reference, so AgeAYear is actually operating on a copy of bobby, not the original. This copy's age increases, but then the copy gets thrown away when the function returns. C++ has a similar behavior and calls this "value semantics."

If you want to mutate bobby, you can use a pointer instead:

func GetOlder(s *Student) {
s.age += 1
fmt.Printf("Age is %d\n", s.age)
}

func main() {
bob := Student{"Bobby", 12}
GetOlder(&bob)
fmt.Printf("Bob's age is %d\n", bob.age)
}

Now this logs 13 twice. Working with pointers has some famous drawbacks, of course, but it's much easier in Go than it is in C or C++ because there's garbage collection.

Pointers in Go have a way of being contagious. Once you use a pointer to a structure in one place, you can get into trouble if you ever pass it by value someplace else. For example:

type SelfReference struct {
name string
self *SelfReference
}

func MakeSelfReference(name string) SelfReference {
me := SelfReference {name: name}
me.self = &me
fmt.Printf("References self? %v\n", me.self == &me) // true
return me
}

func main() {
me := MakeSelfReference("me")
fmt.Printf("References self?: %v\n", me.self == &me) // false
}

This code logs:

References self? true
References self? false

I scratched my head a good long time over an issue like this before realizing that when MakeSelfReference returns, it makes a copy of the object but of course doesn't update the self pointer. So the two get out of sync. If you want to keep them in sync, you want to return a *SelfReference instead.

It took me some time to build an intuition for this because the rules about what's copied can be surprising. An array (which always has a fixed size in Go) is copied by value, but passing a slice (which is like a small struct with a pointer, length and cap) does not make a copy of the underlying array. Another wrinkle (which I won't dive into here) is that depending on how a method is declared, invoking it can implicitly copy the object, too.

All these implicit copies are important and eventually this became intuitive, but it's quite a shift if you're coming from JS!

Batteries not included

Go is definitely not a "batteries included" language: there are many very common and familiar functions that are missing from its standard library, notably reversing a list, the standard map / filter / reduce functions, taking the min/max of two numbers or taking the absolute value of an integer. The argument is that these functions are easy to write, so if you want them, you should just write them. But that does lead to some strange interactions. This VS Code shortcut is a real gem:

VS Code Reverse

I guess this beats copy/pasting from Stack Overflow? My old coworker Dan suggested that this was a way to push you towards reverse iteration, which is faster and requires less memory than allocating a new list. The Go team tends to have very strong opinions about things like this. If you agree with them, then it's fun to watch them take a stand. But if you don't, you wind up having to settle for weird workarounds like this one.

The omission of map / filter / reduce is likely because these functions aren't that useful without generics. And there is some exciting news on that front!

Generics

The Go team accepted a propsal to add generics to the language in mid-2021 and the Go 1.18 beta, which included support for them, came out in the middle of this year's AoC. Generics are a big part of TypeScript, so I was curious to see Go's take on them. I did most of the puzzles using gotip so that I could use generics before they were officially released. Overall this was a pretty smooth process. I found that Go with Generics is a much more pleasant language to use than Go without.

Here's what a generic min function looks like in Go:

import "constraints"
func Min[T constraints.Ordered](nums []T) T {
if len(nums) == 0 {
panic(nums)
}
min := nums[0]
for _, v := range nums[1:] {
if v < min {
min = v
}
}
return min
}

Aside from some syntax differences, this is quite similar to how you'd write this code in TypeScript:

function Min<T>(vals: T[]): T {
if (vals.length === 0) {
throw new Error('Cannot take min of empty list');
}
let min = vals[0];
for (const v of vals.slice(1)) {
if (v < min) {
min = v;
}
}
return min;
}

The most interesting bit in the Go code is the [T constraints.Ordered], which would be similar to T extends Ordered in TypeScript. Go comes with a constraints.go module that defines Ordered and many other constraints that can be placed on type parameters:

// Ordered is a constraint that permits any ordered type: any type
// that supports the operators < <= >= >.
// If future releases of Go add new ordered types,
// this constraint will be modified to include them.
type Ordered interface {
Integer | Float | ~string
}

Both Integer and Float are themselves constraints (while JS/TS just has number, there are many numeric types in Go such as uint32 or float64). The tilde in ~string is also interesting. It matches any type whose "underlying type" is string. This confused me for a while until I realized that, unlike TypeScript, Go has nominal types! For example:

type EnglishWord string
type GermanWord string

This would create two types that are not comparable or assignable to one another. But you can certainly take the min or max of a list of EnglishWords. This is a feature that has been much discussed in TypeScript but is not natively supported. I found nominal types less useful in the Advent of Code than I would have expected. (I only used them to create distinct types for Digit and ScrambledDigit on Day 8).

So is the Go generic code equivalent to this TypeScript?

type Orderable = string | number;
function Min<T extends Orderable>(vals: T[]): T { ... }

Not quite. This TypeScript definition would allow you to take the min of a mixed list of strings and numbers, which should be an error:

const mixedList = [42, 'forty two'];  // type is (string | number)[]
const min = Min(mixedList); // T is string|number, returns a string|number

There's no notion of a union type in Go (more on that in a moment), so T has to be exactly one of the types enumerated in the constraint (or a type with exactly one of those underlying types).

I found Go generics to mostly work as expected and be quite intuitive. They let me factor out some of the repetitive boilerplate that bothered me when I first started with the language.

For example, this is a very common pattern to map a function over a slice:

nums := []int{1, 2, 3, 4}
var squares []int
for _, num := range nums {
squares = append(squares, num * num)
}

With generics, you can factor out a Map function and rewrite this loop:

func Map[T any, U any](vals []T, fn func(T) U) []U {
var us []U
for _, v := range vals {
us = append(us, fn(v))
}
return us
}

squares := Map(nums, func(num int) int { return num * num })

This is quite verbose compared to how it would look in TypeScript:

squares = nums.map(num => num * num);

The TypeScript version is so much shorter because:

  1. JavaScript's arrow functions have a very compact syntax compared to Go's func and return.
  2. TypeScript is able to infer two types: both a) the type of the function parameter (num), which it gets from the type of nums and the type of Array.prototype.map, and b) the return type of the arrow function, which it gets from the function body (num * num). Go infers neither of these, so you have to write out the types. It does infer the return type of the whole Map expression, though ([]int).

I expect that having generics will greatly increase the demand for concise function definitions and better type inference. The Go team will either adopt them or declare that the users asking for them are misguided. It'll almost certainly be one of those two — they're quite opinionated!

There's a famous quote from one of the Go developers (Russ Cox) that when it comes to generics, you can either have:

  1. Slow programmers (i.e. no generics)
  2. Slow compilers (by emitting a different version of a generic function for every type instantiation)
  3. Slow programs (by boxing everything and doing runtime dispatch).

In this scheme, JavaScript forces TypeScript to choose "slow programs" (though JS JITs are quite good!). And TypeScript has a complex enough type system that it's often a slow compiler, even without C++-style compile-time specialization.

Go tries to escape this dilemma by leaving it up to the compiler whether to specialize a function or box it. The whole generics proposal is interesting to read. It only occurred to me halfway through the doc that runtime performance was a consideration. Clearly I've internalized the TypeScript approach to generics!

Overall I found Go generics easy to use and a great addition to the language. It will be interesting to see how they're adopted by the community, and whether they bifurcate libraries. I wanted a Graph library, for example, but there weren't any that used generics. So I just wrote my own.

Other bits

Here are a few other things I found interesting about Go:

  • Go has some extremely error-prone constructs. Two examples:

    • You write a for loop over a slice as for idx, el := range vals. So range gives you the index first and then the value. This means that if you write for el := range vals, you'll iterate over the indices, not the values. I much prefer the JS approach of value then index, e.g. for map and forEach. It seems much more common to only care about the value than to only care about the index.
    • Does a, b := fn() introduce two new variables? It depends. If both a and b exist, then it will create two new variables. But if only a exists, then it will create a new a variable but reassign b. I found this quite surprising!
  • I really missed union types. Go has nothing like them, and the official stance from the Go team is that they "do not add very much" to untyped solutions. 🤮 Remember what I said about the Go team being opinionated?

  • You don't need regular expressions as much as you think for parsing. fmt.Sscanf woked great for most of the Advent of Code problems.

  • Like TS, Go has both var and const but they mean pretty different things.

  • Go doesn't really have a C++-like notion of const or TypeScript's readonly.

  • As in other years, I found that using map[Coord]T instead of [][]T is almost always a good idea. It lets you create sparse arrays and accomodates unknown size or negative indices.

  • Go's type syntax is almost exactly backwards from TypeScript or C, which was a constant source of typos for me. For example, []T instead of T[]. Or for function parameters, min(a int, b int) instead of min(int a, int b). Or even min(a, b int).

  • I still find Go's pickiness about where I put my code on disk to be weird, confusing and counterproductive. Why can't I go build something in a subdirectory?

  • Go types all have a meaningful zero value. I started to learn to work with this more and more as the month went on. For example, nil works perfectly well as an empty slice.

  • I've always been a spaces person but Go uses tabs. It didn't bother me one bit.

My impressions of this year's Advent of Code

The 2021 AoC was harder than 2020 but easier than 2019. As in 2020, none of the solutions built on one another. Each day was a completely independent puzzle. I'm sure Eric Wastl has his reasons for doing it this way, but I missed how 2019's puzzles encouraged you to improve the design of your previous day's solutions. With the independent puzzles, Advent of Code feels more like pure speed coding.

Days 8, 22 and 24 were the real standouts to me as a creative puzzles. For the other days, my first idea on how to solve the puzzle always turned out to be right, even if it took quite a while to implement it. Many, many of the puzzles this year could be solved with some variation on Dijkstra. It's always amazing to see how effective the standard algorithms are once you figure out how to map your problem onto them.

I continue to wish I were in a timezone where it was more reasonable for me to do the puzzles as soon as they came out. And I continue to think this is a great way to learn a language. AoC problems don't really play to Go's strengths (there's no concurrency) but I did feel much more confident writing Go by the end than I did at the start.

Conclusions

Once again, I had a great time doing Advent of Code this year and I enjoyed the opportunity to learn a new language. The days' puzzles gave me something concrete and fun to cling to during a time of complete chaos at my work.

Day 25 in particular stands out. I woke up early (6 AM) to watch the long-anticipated launch of the James Webb Space Telescope, and I raced to collect my final stars before I had to drive off to the airport for a trip to the Dominican Republic, my first international vacation in ~2.5 years.

It's unlikely I'll use Go in my daily work, but I do enjoy the perspective on TypeScript that it gives. As always, TypeScript's relationship to JavaScript (types are erased at runtime) and its ability to infer types stand out. Which language will I try next year? I'm not sure! Suggestions welcome.

If you've made it this far and for some reason want even more, check out the notes and code in my GitHub repo.

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. Now in its second edition, the book's 83 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »