When I first read Go code after years of Node.js, it looked verbose. No classes, no closures over this, error values instead of exceptions, explicit types everywhere. It felt like a step backward.
Six months later, I find Go code easier to reason about than most TypeScript I've written. The verbosity is a feature — it makes implicit behavior explicit. The strict compiler is a feature — it catches entire categories of bugs before runtime. The error-as-value pattern is a feature — it forces you to handle failure at the call site.
This post is the guide I wish I'd had when making the transition.
Node.js is single-threaded with an event loop. Async I/O is non-blocking because callbacks and promises defer work until I/O completes. You think in terms of "don't block the event loop."
Go has goroutines — lightweight threads managed by the Go runtime, not the OS. You can spawn thousands of them with minimal overhead. Blocking a goroutine is fine; the runtime schedules other goroutines while it waits.
// This blocks the goroutine — that's okay
resp, err := http.Get("https://api.example.com/data")
if err != nil {
return nil, err
}
defer resp.Body.Close()
The equivalent in Node.js requires await. In Go, synchronous-looking code is genuinely synchronous within the goroutine, and Go handles the concurrency for you.
In Node.js you're trained to throw errors and try/catch at a higher level. Go has no exceptions. Functions return errors as the last return value, and you check them at every call site:
func readConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("readConfig: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("readConfig: unmarshal: %w", err)
}
return cfg, nil
}
The %w verb wraps the original error so callers can use errors.Is() and errors.As() to inspect error chains. This is more verbose than try/catch. It's also unambiguous — you can read any function and know exactly which operations can fail and where errors are handled.
Go has no classes and no inheritance. Use structs with methods:
type UserService struct {
db *sql.DB
logger *slog.Logger
}
func NewUserService(db *sql.DB, logger *slog.Logger) *UserService {
return &UserService{db: db, logger: logger}
}
func (s *UserService) FindByID(ctx context.Context, id string) (*User, error) {
var user User
err := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1", id).
Scan(&user.ID, &user.Email)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("FindByID: %w", err)
}
return &user, nil
}
The (s *UserService) receiver syntax is the Go equivalent of a class method. Dependency injection happens through the constructor function — no framework required.
Go interfaces are satisfied implicitly. If your type has the right methods, it implements the interface — no implements keyword needed:
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
}
// PostgresUserRepository satisfies UserRepository automatically
type PostgresUserRepository struct{ db *sql.DB }
func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) (*User, error) { ... }
func (r *PostgresUserRepository) Create(ctx context.Context, user *User) error { ... }
This makes mocking in tests trivial. Define a MockUserRepository in your test file, implement the interface methods to return test data, and inject it.
Concurrency in Go is explicit and composable. The sync/errgroup package is the idiomatic way to run multiple goroutines and collect errors:
import "golang.org/x/sync/errgroup"
func fetchDashboardData(ctx context.Context, userID string) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var profile *Profile
var orders []Order
g.Go(func() error {
var err error
profile, err = profileService.Get(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = orderService.List(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &Dashboard{Profile: profile, Orders: orders}, nil
}
Both fetches run concurrently. If either fails, the error propagates and the context cancels the other.
Go isn't universally superior. Node.js has:
Go wins on:
The fastest path from Node.js to productive Go:
net/http before reaching for a frameworkslog for structured logging (standard library, Go 1.21+)pgx for PostgreSQL and sqlc to generate type-safe queries from SQLThe compiler is strict and the error messages are clear. Trust the compiler. The verbosity you see at first becomes the clarity you appreciate at 2am when something is wrong in production.