Five ways to keep a Go backend and a TypeScript frontend in type lockstep, each building the same small app so the comparison is honest. The companion piece to the article of the same name.
Every approach implements one task tracker with the same five features, chosen because each one breaks a different part of a typing pipeline:
- linked entities (
User,Project,Task) - a closed enum (
TaskStatus) whose wire zero value is not a real state - a nullable field (
assignee) next to an optional field (dueDate), so null and absent stay distinct - cursor pagination (
listTasks) - a typed not-found error on
getTask - mutations (
createTask,updateTaskStatus)
The point that most comparisons skip: generated code gives you transport types, not domain types. Generated IDs are plain strings, generated enums carry a meaningless zero value, generated presence flags cannot tell null from absent.
So the domain lives in one place, independent of any transport:
domain/(Go) holds branded id types, the status enum with an explicit unknown zero value, typed errors, and an in-memory store.packages/domain-ts/(TypeScript) holds the branded zod schemas. The schema is the parser: parse once at the boundary, then trust the branded type everywhere.
Each approach then writes a thin anti-corruption layer that maps its generated transport types onto this domain. The cost of that mapping, per approach, is the real subject of the article.
| Directory | Source of truth | Branded types | Port |
|---|---|---|---|
connectrpc/ |
proto | adapter layer | 8080 |
openapi-spec-first/ |
OpenAPI 3.0 document | adapter layer | 8081 |
openapi-code-first/ |
Go structs (Huma) | adapter layer | 8082 |
manual/ |
hand-written types | zod brand (native) | 8083 |
graphql/ |
GraphQL schema | custom scalars (native) | 8084 |
Each directory has its own README with run and codegen instructions. There is
also payload-size/, a small program that compares the on-the-wire byte size of
the same task across the formats, and article/, the long-form write-up.
- Go modules per approach, wired to the shared
domainmodule with areplacedirective so each directory is self-contained. Ago.workties them together for editing. - pnpm workspace for the TypeScript side; clients depend on
@gotstypes/domain. - Parse don't validate, everywhere: untrusted input is parsed into a precise type at the edge, and nothing downstream re-checks it.
Prerequisites: Go 1.26+, Node 22+, pnpm. ConnectRPC and GraphQL also need buf
and gqlgen to regenerate, but the generated code is committed, so you can run
without them.
pnpm install # TypeScript workspace
pnpm --filter @gotstypes/domain buildRun any approach end to end (Connect shown, swap the directory for the others):
cd connectrpc/server && go run . # terminal 1, serves on :8080
cd connectrpc/client && pnpm demo # terminal 2, exercises every RPCTests and the size comparison:
cd domain && go test ./... # shared Go domain
pnpm -r test # all TypeScript packages
cd payload-size && go run . # wire-size comparisonCode is MIT licensed (see LICENSE). The Go gopher in the logo is by Renée French,
vectorized by Takuya Ueda, used under CC-BY 3.0.