The Go Mental Model
Shifting gears from TypeScript to Go
Section titled “Shifting gears from TypeScript to Go”Before writing a single line of Go, it helps to unlearn a few Node/TypeScript habits. Go is not a “TypeScript without the browser” — it is a different set of trade-offs, deliberately chosen. This lesson walks through the five biggest mindset shifts.
1. Compiled vs interpreted (no node at runtime)
Section titled “1. Compiled vs interpreted (no node at runtime)”TypeScript is source → JS → runtime (Node.js). You almost always run TypeScript through a runtime process. Go compiles to a native binary. Once built, ./myapp runs directly on the OS — no runtime dependency.
// TypeScript workflow// tsc src/index.ts --outDir dist// node dist/index.js// (Node.js must be installed on the server)// Go workflow// go build -o myapp .// ./myapp// (nothing else needed on the server)This matters for Docker images (Go images can be FROM scratch), Lambda cold starts, and CLI distribution — you ship one file.
2. Static binary vs node_modules
Section titled “2. Static binary vs node_modules”TypeScript projects have a package.json and a node_modules directory that can reach hundreds of megabytes. Go uses a module system (go.mod) where dependencies are downloaded at build time and compiled into the binary. There is no node_modules equivalent on the server.
// package.json{ "dependencies": { "express": "^4.18.2", "zod": "^3.22.0" }}// node_modules/ → shipped separately or re-installed// go.modmodule github.com/yourname/myapp
go 1.22
require ( github.com/gin-gonic/gin v1.9.1)// dependencies compiled into the binary — nothing to ship separately3. Goroutines vs the event loop
Section titled “3. Goroutines vs the event loop”Node.js is single-threaded. Its concurrency comes from the event loop: async operations are offloaded to libuv and a callback (or Promise) runs when they complete. You never run two JavaScript callbacks at the same time.
Go uses goroutines — lightweight concurrent functions. The Go runtime multiplexes thousands of goroutines across real OS threads (GOMAXPROCS). Two goroutines genuinely run in parallel on multi-core machines.
// TypeScript: async/await, single threadasync function doWork() { const a = await stepOne(); // event loop yields const b = await stepTwo(); // event loop yields return a + b;}// Go: goroutines, real parallelismfunc doWork(ch chan int) { a := stepOne() // runs on goroutine b := stepTwo() // runs on goroutine ch <- a + b // send result on channel}// launch with: go doWork(ch)Goroutines and channels are covered in depth in a later module. For now, the mental model is: Go concurrency is parallel by default, not just async.
4. Explicit errors vs exceptions
Section titled “4. Explicit errors vs exceptions”This is the biggest habit to break. In TypeScript/JavaScript, anything can throw. You guard with try/catch, but you cannot tell from a function’s signature whether it throws. In Go, errors are return values — part of the function contract.
// TypeScript: any function can throwfunction parseConfig(raw: string): Config { // might throw SyntaxError — not in the signature! return JSON.parse(raw);}
try { const cfg = parseConfig(raw);} catch (e) { // handle}// Go: error is in the return type — impossible to ignorefunc parseConfig(raw string) (Config, error) { var cfg Config if err := json.Unmarshal([]byte(raw), &cfg); err != nil { return Config{}, fmt.Errorf("parseConfig: %w", err) } return cfg, nil}
cfg, err := parseConfig(raw)if err != nil { // must handle — compiler warns if you don't use err}5. Simplicity — one idiomatic way
Section titled “5. Simplicity — one idiomatic way”TypeScript has many ways to do the same thing: classes, prototypes, functions, decorators, multiple module formats. The community debates patterns constantly. Go’s culture values the opposite: there is usually one idiomatic way, enforced by gofmt (automatic formatting), go vet (static analysis), and community convention. If two Go developers write the same function independently, their code will look nearly identical.
// TypeScript: many valid stylesclass UserService { constructor(private db: DB) {} async getUser(id: string) { ... }}// OR a plain functionconst getUser = async (db: DB, id: string) => { ... }// OR a factory function, OR a namespace, OR...// Go: one clear idiomatic styletype UserService struct { db *DB}
func NewUserService(db *DB) *UserService { return &UserService{db: db}}
func (s *UserService) GetUser(ctx context.Context, id string) (User, error) { // ... return User{}, nil}Playground: errors as values
Section titled “Playground: errors as values”package main
import ( "errors" "fmt")
func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil}
func main() { result, err := divide(10, 2) if err != nil { fmt.Println("Error:", err) return } fmt.Printf("10 / 2 = %.1f\n", result)
_, err = divide(5, 0) if err != nil { fmt.Println("Caught error:", err) }}Loading Go runtime (first run only, ~8 MB)…