Skip to content

Repository files navigation

Go gopher holding the TypeScript logo

End-to-end type safety between Go and TypeScript

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.


The shared app

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 shared domain

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.

The approaches

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.

Conventions

  • Go modules per approach, wired to the shared domain module with a replace directive so each directory is self-contained. A go.work ties 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.

Getting started

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 build

Run 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 RPC

Tests and the size comparison:

cd domain && go test ./...    # shared Go domain
pnpm -r test                  # all TypeScript packages
cd payload-size && go run .   # wire-size comparison

Credits

Code 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.

About

End-to-end type safety between Go and TypeScript: ConnectRPC, OpenAPI, GraphQL and manual, compared with one shared domain.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages