Go for TypeScript Engineers.
The mental model shifts that helped me stop writing TypeScript-shaped Go and start writing idiomatic Go.
I’ve been writing TypeScript for most of my career. When I started writing Go seriously, I kept reaching for patterns that don’t exist — and getting frustrated when the language resisted them.
The unlock wasn’t learning Go syntax. It was unlearning TypeScript instincts.
There is no undefined
In TypeScript, undefined is everywhere. It’s how you represent absence. You learn to check for it constantly, use optional chaining, write foo?.bar?.baz.
Go has two ways to represent absence: the zero value, and pointers. Most of the time, the zero value is what you want.
An int with no value assigned is 0. A string is "". A bool is false. These are valid states, not uninitialized memory. If you need to distinguish “not set” from the zero value, then you use a pointer — *int, *string — and check for nil.
This sounds subtle. It has a massive effect on code. You stop writing defensive checks everywhere and start thinking about which fields in a struct actually need to represent absence.
Interfaces are implicit and structural
TypeScript has structural typing for objects and explicit implements is optional. Go goes further — interfaces are completely implicit.
You don’t write implements Reader. You just have a method with the right signature, and your type satisfies the interface automatically. The interface is defined by the consumer, not the producer.
This means you can define a one-method interface in your package, and any type — including ones from third-party libraries — that has that method satisfies it. It’s one of the best things about Go’s type system once you internalize it.
Error handling is not exception handling
In TypeScript I wrote try/catch constantly. In Go you return (value, error) and check at every call site.
The first reaction is: this is verbose. That’s correct. It is verbose. That’s intentional.
The verbosity is information. When you write:
user, err := db.GetUser(id)
if err != nil {
return nil, fmt.Errorf("getting user %d: %w", id, err)
} You’re being explicit that this can fail, and you’re wrapping the error with context as it propagates up. The call stack is documented in the error chain.
Exceptions let errors silently propagate until something catches them. Go’s model forces you to think about failure at the point it happens.
Goroutines are not promises
async/await in TypeScript is cooperative multitasking on a single thread. Goroutines are genuinely concurrent, and the scheduler can put them on different OS threads.
The mental model shift: in TypeScript, you don’t worry about data races because you can’t have them (single thread). In Go, any data touched by more than one goroutine needs synchronization — channels, mutexes, or atomic operations.
Start with channels for communication between goroutines. Reach for sync.Mutex when you need to protect a shared resource. Avoid sharing memory by communicating rather than communicating by sharing memory — it’s a Go proverb, and it’s good advice.
The standard library is the answer
In the TypeScript ecosystem, reaching for npm is the default. Need to parse a date? Install date-fns. Need to make HTTP requests? Install axios.
In Go, the standard library covers a remarkable amount of ground. The net/http package is production-grade. encoding/json handles most JSON needs. database/sql works with every SQL driver.
Before reaching for a third-party package in Go, check if the standard library does it. Often it does, and the standard library doesn’t have breaking changes.
Go is a smaller language than TypeScript in terms of features. That’s not a weakness — it means there are fewer ways to do any given thing, so code across a team or codebase looks more consistent. Once the TypeScript patterns stopped feeling like the default, that consistency became something I appreciated.