TypeScript erases types. Go keeps them. A duck-typed object that "has id and email" is a TypeScript habit. In Go the compiler wants a named struct or an interface — and that interface is a method set, not a bag of fields. A net/http handler that decodes JSON into a struct is the same instinct as a Zod-validated Hono route — the check lives in a type the binary still knows, not in a schema that vanished at tsc.
This note is the mapping used when reading a Go 1.22+ API as a Hono developer. Samples target the standard library: method-aware ServeMux, encoding/json, database/sql. sqlc and go-playground/validator appear once, as the Drizzle and Zod cousins. chi appears once, as the middleware-composition cousin. It is not a rewrite of this site in Go, and it is not Gin. The typed Hono stack is Backend APIs with Hono, Drizzle, Zod OpenAPI, and SST. The Python sibling, where the types are annotations until the HTTP boundary, is Learning Python as a TypeScript Developer. The Java sibling, where the types survive but interfaces are nominal, is Learning Java as a TypeScript Developer. The object model you already have is TypeScript Classes and Runtime Identity.
TS / Node: .ts → tsc/bun erase types → V8, one event loop
Go / gc: .go → gc keep types → native binary, goroutinesFour kinds of statements:
- A TypeScript contract, such as an interface that vanishes at runtime, or
throwwith no type. - A Go language rule, such as implicit interface satisfaction, or
(User, error). - A runtime observation, such as the M:N scheduler, or a race on a map. Application code must not depend on scheduler internals beyond that.
- A
net/httpconvention, such as aServeMuxpattern and a struct withjsontags.
1. What This Note Is
A TypeScript engineer already has the right abstractions: a request in, JSON out, a validated body, a query against Postgres, a session. Go remaps each of those onto a different runtime.
- Types survive compilation. The binary still knows the struct. An interface value is a type plus a value. A TypeScript
interfaceis gone. - The unit of code is a package. Functions are first-class. There is no
class, noexport, and no one-type-per-file rule. Capitalization is visibility. - The host is a native process, not V8 and not a JVM. Many goroutines share a heap. Blocking I/O is normal. There is no event loop draining microtasks.
net/httpis a router you assemble. Hono is a router you assemble with Zod. Spring is a container that constructs the router for you. Go is the Hono instinct: you wire the mux.
The throughline is a small users resource: fetch by id, list, create. Enough to read a handler, a struct, and a query.
2. Interfaces Are Structural and Survive
TypeScript is structural at compile time and erased after. Java is nominal at both. Go is the third shape: structural at compile time, real at runtime. If the method set matches, it implements. You never write implements.
interface UserLike {
id: string
email: string
}
function greet(u: UserLike): string {
return u.email
}
greet({ id: "1", email: "a@b.c" }) // fields are the contracttype UserLike interface {
Email() string
}
func greet(u UserLike) string {
return u.Email()
}
type User struct {
ID string
email string
}
func (u User) Email() string { return u.email }- A Go interface is a method set, not a bag of fields.
{ id, email }is not a type. The methods are. That is where the TypeScript analogy breaks first. - Satisfaction is implicit.
UserimplementsUserLikebecauseEmail()exists. There is noimplements UserLikeand noextends. Add a method to the interface and every implementer must grow it, or the build fails. - At runtime an interface value is a type and a value. Type assertion (
u.(User)) and a type switch are facts. Stance: TypeScript Classes and Runtime Identity —instanceofon a TypeScript interface is a lie;u.(User)is not. anyisinterface{}. It is the escape, not the default. A function that takesanyhas given the compiler nothing.
Failure: treating a Go interface like a TypeScript interface { id: string }. There is no field-shaped interface. Put a method on the type, or pass the struct.
3. nil, Zero Values, Pointers
TypeScript has null and undefined. Go has nil for pointers, slices, maps, channels, functions, and interfaces — and zero values for everything else. A string is "". An int is 0. A User is User{}. There is no hole you forgot to initialize.
function emailOf(user: User | null): string | undefined {
return user?.email
}func emailOf(user *User) *string {
if user == nil {
return nil
}
return &user.Email
}nilis notundefined. An uninitialized*Userisnil. An uninitializedUseris zeros. Calling a method on a nil pointer can panic; calling one on a zero struct usually does not.- Pointers are a copy rule, not C.
func f(u User)copies the struct.func f(u *User)shares it. Use a pointer receiver to mutate; use a value receiver when the method is a read. - JSON
omitemptyon astringhides"". On a*stringit hidesniland keeps"". The zero value and "missing" are different facts. Pick the type that matches the JSON you mean. - An interface holding a typed nil is not
nil.var p *User; var u UserLike = pthenu == nilis false. The interface has a type.
Failure: if user != nil on a User value. It is never nil. Check the pointer, or check a field you actually care about.
4. Structs, Methods, No Classes
In TypeScript a class is a constructor you usually avoid. In Java a class is how code is organized. In Go there is no class. A struct is data. A method is a function with a receiver. A constructor is a function named NewUser by convention.
type User struct {
ID string
Email string
}
func NewUser(id, email string) User {
return User{ID: id, Email: email}
}
func (u User) Greet() string {
return u.Email
}
func (u *User) SetEmail(email string) {
u.Email = email
}- The receiver is
(u User)or(u *User). It isthisyou wrote down. Mixing them on the same type works; the compiler will take the address when it must. Pick one for the method set you want to expose. - Embedding is composition.
type Admin struct { User }promotesUser's methods. It is not inheritance. There is nosuper, no virtual dispatch beyond the interface you already wrote. - There is no
newthat runs a constructor.User{}is valid and zero.NewUserexists because you wanted a rule, not because the language required one.
Failure: a UserService struct with one method and no state. That is a package-level function with extra steps. Methods earn their receiver.
5. Generics
TypeScript generics invent types: extends, conditionals, infer. Go generics parameterize a function or a type. They do not branch on the type.
function idsOf<T extends { id: string }>(xs: T[]): string[] {
return xs.map((x) => x.id)
}func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, len(xs))
for i, x := range xs {
out[i] = f(x)
}
return out
}anyas a constraint means no constraint. A real constraint is an interface:func Sum[T int64 | float64](xs []T) T. Approximate types (~int) include defined aliases.- There is no
extends { id: string }on a field. Constraint the method set, or pass a function that reads the field. The compiler will not walk a struct for you. - No variance, no conditional types, no mapped types. If you are writing a
T extends ... ? A : B, you are still in TypeScript. In Go, write two functions or an interface.
Failure: a generic Repository[T] that uses any and reflection to insert a row. sqlc already generated the typed function. Use it.
6. Slices, Maps, Arrays
JavaScript has Array and object. Go splits that into a slice, a map, and a rare array. A JSON list arrives as a slice. A JSON object arrives as a struct you decode, or a map[string]any you should not return.
const ids = users.map((u) => u.id).filter(Boolean)ids := make([]string, 0, len(users))
for _, u := range users {
if u.ID != "" {
ids = append(ids, u.ID)
}
}- A slice is a view: pointer, length, capacity.
appendmay allocate a new backing array.var s []stringis nil.s := []string{}is empty.json.Marshalof nil isnull; of empty is[]. That is the 2 a.m. bug. - A map is
Record<string, V>. It is not ordered. It is not safe for concurrent write.var m map[string]Useris nil; assignment panics untilmake. Lookup returns the zero value and a bool:u, ok := m[id]. - An array
[3]stringis a value. It is not a slice. You almost never want it at an API boundary.
Failure: returning map[string]any from a handler because json.Marshal accepts it. That is the untyped dict from the Python note. Decode into a struct.
7. Errors, Not Exceptions
TypeScript throw is untyped. Java has checked exceptions. Go returns (T, error). There is no stack unwind for a missing user. panic is for "this process is wrong," not for 404.
var ErrNotFound = errors.New("user not found")
func require(users Store, id string) (User, error) {
user, err := users.FindByID(id)
if err != nil {
return User{}, err
}
return user, nil
}user, err := require(users, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}- The idiom is
if err != nil { return ..., err }. It is noisy on purpose. Swallowingerris the Javacatch (Exception). errors.Is/errors.Asunwrap. A sentinel (ErrNotFound) is a value you compare. A typed error is a struct youAs. Wrap with%wso the sentinel survives.- Do not return a zero
Userandnilfor a miss, and also returnErrNotFoundfor the same miss. One miss, one signal. The handler maps that signal to a status.
Failure: panic(err) in a handler because "it looks like throw." The process dies, or a recover middleware turns every bug into a 500 with no type. Return the error.
8. Packages, go.mod, Export
A TypeScript file is a module. A Go file is a member of a package. The directory is the package. Capitalization is export.
module github.com/example/api
go 1.22
cmd/api/main.go // package main
internal/users/users.go // package users
go.mod
go.sumUseris exported.useris not. There is noexportkeyword and nopublic. The compiler enforces the case. Cross-package tests cannot reachuser.go.modispackage.json. The module path is the import prefix.go.sumis the lock. The module cache is notnode_modulesnext to the binary; it is a shared cache the compiler reads at build.internal/is a visibility wall the compiler honors. Only the tree above it can import it. That is stronger than a leading underscore in Python.package mainplusfunc mainis the entry. Everything else is a library package. There is noindex.tsbarrel you re-export from unless you want one.
Failure: package users in two directories, or exporting type user struct and wondering why the handler cannot name it. The directory is the package. The case is the export.
9. Goroutines vs Node
Node is one thread for JavaScript, plus a pool for I/O. The model is Core JavaScript Concepts. Go is many goroutines, one process, multiplexed by an M:N scheduler onto OS threads.
Node: stack (JS) → microtasks → one macrotask → poll I/O
Go: M:N scheduler
goroutines multiplexed onto OS threads
blocking I/O blocks that goroutine, not the processgo f()starts a goroutine. It is notvoid f()and it is notPromise. The caller does not wait. In a handler, the request returns when the handler returns — a straygocan outlive the request, or lose thecontext.Contextcancel. Passr.Context()and wait, or do not spawn.- A blocking
QueryRowis fine. That goroutine waits. Others run. You do notasync/awaita database call. You pass a context with a deadline. - Shared mutable state is the crash. A
mapwritten from two goroutines without a mutex is a data race.go test -raceis the tool. Channels andselectcoordinate; they are not a substitute for "do not share the map."
Failure: go users.Create(...) inside a handler so the response feels fast. The client got 201. The insert failed. The context was already canceled. Do the work, then write the status.
10. Build
package.json plus a lockfile is the TypeScript habit. Go splits a module file from a static binary.
| TypeScript / bun | Go | |
|---|---|---|
| Manifest | package.json | go.mod |
| Lock | bun.lock | go.sum |
| Install | node_modules | module cache ($GOPATH/pkg/mod) |
| Ship | a server process, or a bundle | a static binary |
| Entry | src/index.ts | func main in package main |
go buildemits one file. Production ships that file, usually in a scratch or distroless image. There is no interpreter and nonode_modulesin the container.CGO_ENABLED=0keeps it static.- Cross-compile is a pair of env vars:
GOOS=linux GOARCH=arm64 go build. There is no separate "target" toolchain to install for that. cmd/apiis the process.internal/is the library. Tests live next to the code as*_test.go. There is nosrc/main/javaceremony.
Failure: copying the module cache into the image because "that is node_modules." The binary already linked what it needs. Ship the binary.
11. net/http as Hono
Hono is a function you call with a request. net/http is the same instinct: a mux of patterns, each a function of ResponseWriter and *Request. Go 1.22 gave the stdlib method-and-path patterns. You do not need a framework to get /users/{id}.
app.get("/users/:id", async (c) => {
const id = c.req.param("id")
const user = await users.require(id)
return c.json(user)
})mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user, err := users.Require(r.Context(), id)
if err != nil {
http.Error(w, "user not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
})
http.ListenAndServe(":8080", mux)"GET /users/{id}"isapp.get("/users/:id").r.PathValue("id")isc.req.param("id"). There is no decorator and no bean. You registered a function.- Middleware is
func(http.Handler) http.Handler. Wrap the mux. chi is that wrapper with a nicerUseand subrouters. It is stillnet/http. It is not a container. Start with the mux; take chi when the nesting gets noisy. http.Erroris the typed exit you write yourself. There is noHTTPExceptiontype in the language. Maperrat the handler, or write one helper and reuse it.func mainisindex.ts.ListenAndServeis the process. There is no Spring scan and no FastAPIappobject beyond the mux you built.
Failure: a global users map mutated from handlers, or ListenAndServe without a Server that honors shutdown. The mux is fine. The process still needs a deadline.
12. encoding/json and sqlc
Zod is a schema you parse. Go's schema is the struct tag. Drizzle is SQL in TypeScript. sqlc is SQL in a .sql file that generates the Go. database/sql is the pool.
const UserCreate = z.object({
email: z.string().email(),
})
await db.insert(users).values({ email }).returning()type UserCreate struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}-- name: GetUser :one
SELECT id, email FROM users WHERE id = $1;user, err := q.GetUser(ctx, id)jsontags name the wire fields.Decodefills the struct. Unknown JSON keys are dropped unless youDisallowUnknownFields. That is not Zod: there is no.email()in the type. go-playground/validator (validate:"required,email") is the cousin that runs after decode. Use it at the HTTP edge, not on the SQL row.- sqlc is Drizzle's closest cousin. You write the statement. It generates
GetUser(ctx, id). The row type is generated. There is no ORM session and no lazy graph. Postgres still evaluates the statement: Core SQL Concepts. Tenant filters still belong in the query and in RLS: Building a Multi-Tenant Backend with Hono, Better Auth, Drizzle, and Postgres RLS. database/sql(orpgxas the driver) is a pool. Passcontext.Context. Do not hold a*sql.Txacross requests. Map the sqlc row → JSON struct at the handler if they ever diverge.
Failure: scanning into map[string]any or returning the sqlc row with a password_hash column because "it already has json tags." Map at the boundary, same instinct as entity → record in the Java note.
13. Where It Sits
Go is how this binary types a value. net/http is how this process receives a request. Neither replaces authorization, SQL, or the session.
- The interpreter sibling: Learning Python as a TypeScript Developer
- The JVM sibling: Learning Java as a TypeScript Developer
- Runtime identity in TypeScript: TypeScript Classes and Runtime Identity
- When JS runs: Core JavaScript Concepts
- The Hono analog: Backend APIs with Hono, Drizzle, Zod OpenAPI, and SST
- The statement: Core SQL Concepts
- Identity, membership, and the row: Building a Multi-Tenant Backend with Hono, Better Auth, Drizzle, and Postgres RLS