Share Types Between a Go Backend and TypeScript Frontend
Compare five ways to keep Go response types and TypeScript interfaces in step, then run the cheapest one, shared golden fixtures, end to end in CI.

Every project with a Go API and a TypeScript front end starts the same way. Someone writes a TypeScript interface by reading the Go struct. It matches on the day it is written. Six weeks later a field is renamed on the server, the front end keeps sending and expecting the old name, and the bug appears in a screenshot from a customer. Nothing was wrong with either side's code. Nothing connected them.
Tested with Go 1.25.6, TypeScript 5.3.3, macOS on Apple Silicon (arm64)
This article is about connecting them. It compares the realistic ways to share types, runs the cheapest one end to end, and covers the shape problems that no tool fixes for you. If you have already decided to generate and want the mechanism, generating TypeScript from Go structs builds a generator. If your API has more consumers than one front end, the OpenAPI contract article is a better starting point. Everything here ran on Go 1.25.6 and TypeScript 5.3.3.
What "sharing types" can mean
You cannot import a Go struct into TypeScript. Sharing therefore means one of three things: generate one side from the other, generate both from a third description, or keep two hand-written definitions honest with an automated check. The choice is about who owns the truth.
| Approach | Source of truth | What stays in sync | Cost |
|---|---|---|---|
| Hand-written both sides, with shared fixtures | Go behaviour | Shape, checked by tests | Lowest. No new tools |
| Generate TypeScript from Go structs | Go structs | Types | A generator in your build |
| OpenAPI first | An OpenAPI file | Server interface, client types, docs | A spec to maintain and review |
| Protobuf or Connect | .proto files | Types on both sides, plus the wire format | A new toolchain and a binary or JSON wire contract |
| JSON Schema | Schema files | Types and runtime validation | Schemas to keep and two generators |
None of these is correct for every team. Each one moves the cost from "someone remembers" to "something runs".
Approach 1: fixtures that both sides check
The cheapest option is also the least discussed. The Go tests write down what the API actually puts on the wire, as JSON files in the repository. The TypeScript project imports the same files and asks its compiler whether they match the interfaces the front end uses. If either side moves, a compiler or a test fails in the repository that moved.
Start with the Go side: a struct and a golden test.
package main
import "time"
type Line struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
UnitCents int64 `json:"unit_cents"`
}
type Invoice struct {
ID string `json:"id"`
Customer string `json:"customer"`
Lines []Line `json:"lines"`
TotalCents int64 `json:"total_cents"`
PaidAt *time.Time `json:"paid_at"`
}
func sample() Invoice {
return Invoice{
ID: "inv_1001", Customer: "Acme Ltd",
Lines: []Line{{SKU: "gopher-plush", Quantity: 2, UnitCents: 1500}},
TotalCents: 3000,
}
}package main
import "fmt"
func main() { fmt.Println(sample().ID) }package main
import (
"encoding/json"
"flag"
"os"
"testing"
)
var update = flag.Bool("update", false, "rewrite the fixtures from the Go types")
// The fixture is what the Go API puts on the wire. The TypeScript project type-checks the same file,
// so a change to the Go struct has to pass through here, where both teams can see it.
func TestInvoiceWireShape(t *testing.T) {
got, err := json.MarshalIndent(sample(), "", " ")
if err != nil {
t.Fatal(err)
}
got = append(got, '\n')
const path = "fixtures/invoice.json"
if *update {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != string(want) {
t.Fatalf("wire shape changed; review the diff and run go test -update\n--- committed\n%s--- now\n%s", want, got)
}
}The test marshals a representative Invoice and compares it with a committed file. With -update it rewrites the file, which is how a deliberate change gets accepted: you run it, read the diff in review, and commit the new fixture. Without the flag, any difference is a failure that prints the old and new JSON.
The TypeScript side has hand-written interfaces and a file that assigns the fixture to them:
export interface Line {
sku: string;
quantity: number;
unit_cents: number;
}
export interface Invoice {
id: string;
customer: string;
lines: Line[];
total_cents: number;
paid_at: string | null;
}import type { Invoice } from "./api";
import current from "./fixtures/invoice.json";
import renamed from "./fixtures/invoice-v2.json";
export const ok: Invoice = current;
export const drifted: Invoice = renamed;check.ts imports two fixtures. The first is the one Go writes. The second, invoice-v2.json, stands in for what the server would produce after a rename of total_cents to amount_cents:
{
"id": "inv_1001",
"customer": "Acme Ltd",
"lines": [{ "sku": "gopher-plush", "quantity": 2, "unit_cents": 1500 }],
"amount_cents": 3000,
"paid_at": null
}Generate the fixture and run the Go test:
go test -run WireShape -update -count=1 .
cat fixtures/invoice.json
go test -count=1 -v .PASS
ok example.test/lab 0.277s
{
"id": "inv_1001",
"customer": "Acme Ltd",
"lines": [
{
"sku": "gopher-plush",
"quantity": 2,
"unit_cents": 1500
}
],
"total_cents": 3000,
"paid_at": null
}
=== RUN TestInvoiceWireShape
--- PASS: TestInvoiceWireShape (0.00s)
PASS
ok example.test/lab 0.198sThen type-check the front end against it:
tsc --noEmit --strict --esModuleInterop --resolveJsonModule --moduleResolution node --module esnext api.ts check.tscheck.ts(6,14): error TS2741: Property 'total_cents' is missing in type '{ id: string; customer: string; lines: { sku: string; quantity: number; unit_cents: number; }[]; amount_cents: number; paid_at: null; }' but required in type 'Invoice'.Reading the results
The first run wrote the fixture from the real struct, and the following go test passed against it. The TypeScript compiler found nothing to say about ok, but flagged drifted: Property 'total_cents' is missing in type, with the full shape of the new object so you can see what changed. In a real repository invoice-v2.json does not exist. The fixture from Go is the only input, so the error appears when Go's fixture changes and api.ts has not followed.
The other half of the safety net is on the Go side. If someone renames a JSON tag in the struct, the golden test fails. This block copies the project, renames unit_cents to unit_price_cents and runs the tests:
d=$(mktemp -d)
cp -r . "$d" && cd "$d"
perl -pi -e 's/"unit_cents"/"unit_price_cents"/' invoice.go
go test -count=1 . 2>&1 | head -24--- FAIL: TestInvoiceWireShape (0.00s)
golden_test.go:32: wire shape changed; review the diff and run go test -update
--- committed
{
"id": "inv_1001",
"customer": "Acme Ltd",
"lines": [
{
"sku": "gopher-plush",
"quantity": 2,
"unit_cents": 1500
}
],
"total_cents": 3000,
"paid_at": null
}
--- now
{
"id": "inv_1001",
"customer": "Acme Ltd",
"lines": [
{
"sku": "gopher-plush",
"quantity": 2,The failure prints the committed file and the new output, which is exactly what a reviewer needs. The change is not blocked forever; it is blocked until someone runs go test -update and commits the fixture, which puts a visible diff in the pull request. That diff is the signal for the front end team.
Why this is enough for many teams
It checks the thing that actually breaks: the shape on the wire. It needs no code generation, no schema language and no new CI stage beyond go test and tsc. It works in a monorepo by importing the fixture path and across repositories by publishing the fixtures directory as a small package or git submodule.
It does not remove duplication. You still write each interface by hand, and a field the fixture omits is not checked. Make the sample values cover every field, including optional ones present and absent. A second fixture for the "empty" case is cheap and catches the nil-versus-empty problems below.
Approach 2: generate TypeScript from Go
The generated route removes the hand-written interfaces. Tools such as tygo read your Go packages and write .ts files, and a reflection based generator like the one in the previous article does the same from compiled types. The Go struct is the truth, and the front end imports the output.
It is a good fit when the Go and TypeScript code live in one repository, one team owns both, and the API has no other consumers. The weaknesses: the generator has to understand MarshalJSON methods and generics (most do not), the output describes shape and not constraints, and the Go types become the public contract by accident. Renaming a Go struct field renames the API unless you are careful with tags.
Approach 3: OpenAPI as the source
Here the contract is a document. You generate Go server stubs with a tool like oapi-codegen and TypeScript types with openapi-typescript, as described in generating servers from OpenAPI. Both sides depend on the file, so neither can drift from it without a diff in the spec.
This is the right choice when the API has several clients, public documentation, or a separate team on the front end. The price is maintaining the spec and the discipline of changing it first.
Approach 4: protobuf or Connect
If you are already using gRPC or Connect, the .proto files are your shared definition, and protoc-gen-es generates TypeScript messages that match the Go ones, including the exact wire format. You get a typed client for free. The cost is the protobuf toolchain and its JSON conventions: int64 as a string, enums as names, oneof as a particular object shape, which you either accept or fight.
Approach 5: JSON Schema
JSON Schema files, or schemas generated from Go with libraries such as invopop/jsonschema, give you types in TypeScript through json-schema-to-typescript and validation at runtime in both languages. It is attractive when you need to validate untrusted JSON in the browser or in a Go service from the same source. In practice, the tooling is uneven and you are maintaining a third representation, so choose it for the validation, not for the types.
Shape problems no tool solves for you
These are the places where a perfectly synced type still lies. Settle each one in writing.
Nil slices. In Go, a nil slice marshals as null and an empty slice as []. The TypeScript type has to say T[] | null, or the Go code must always initialise. The cleanest rule is to never return a nil slice from handlers, and to have a test that fails when one slips through. See JSON null versus omitted.
Integers above 2^53. JavaScript numbers are doubles. An int64 id can lose precision in the browser. Send big identifiers as strings (json:"id,string" or a string field) and type them as string.
Timestamps. Go writes RFC 3339 strings. TypeScript types them as string; turning them into Date happens at the boundary, once, in the client wrapper. Use UTC and a fixed layout, as covered in time formatting.
Enums and unknown values. A closed union of string literals is convenient until the server adds a value. Decide how the front end behaves on an unknown one: show a default, hide the row, report to your error tracker. Typing the field as a literal union and handling the default branch explicitly keeps that decision visible.
Optional versus nullable. omitempty makes a key absent. A pointer without omitempty makes it null. TypeScript has ? for the first and | null for the second, and conflating them hides bugs.
Unknown fields. Servers add fields. Front ends must ignore ones they do not know. Strict decoding on the server side is a Go concern covered in DisallowUnknownFields; on the client, avoid validators that reject extra keys unless you want deployments to be lock-step.
Layout and ownership
Where the fixtures live decides whether the check gets used. In a monorepo, put them in a top-level contracts/ directory, let the Go package write there from its tests, and point the front end's tsconfig at the same path. Both sides see one file, and a pull request that touches it shows up for review by whoever owns each side.
With separate repositories you have two reasonable options. The Go repository can publish the fixtures as a versioned archive that the front end downloads in CI, or the front end repository can vendor them with a script that fails when they are stale. In both cases the version of the fixtures is part of the deployment story: a front end built against fixtures from last week should be deployed only after a server that still emits that shape. Writing that rule down is part of the work.
Give the directory an owner in CODEOWNERS. A rule as short as "changes under contracts/ need one approval from backend and one from frontend" turns a silent drift into a conversation, and the conversation is the thing you wanted in the first place.
Keep fixtures small and meaningful. One file per response type is usually enough, plus a second for the edge case that bit you before: an empty list, a null timestamp, a missing optional block. A hundred generated fixtures that nobody reads add noise without adding safety.
Checking at runtime
Compile-time types describe what you hope arrives. Responses cross a network, pass through proxies and can be served by an old version of the server during a rollout. For code paths that matter, validate on arrival with a library such as Zod or Valibot, and report failures with the endpoint and the offending path. A schema can be written by hand or derived from the generated types. Do not validate every response in a hot path without measuring; validate at the edges where the data first enters the app.
Putting it in the pipeline
A workable pipeline for the fixture approach looks like this:
go test ./...runs the golden tests. A failure means the wire shape changed.- The author reviews the fixture diff, runs
go test -update, and commits the new files. - CI in the front end runs
tsc --noEmit, which type-checks the fixtures against the interfaces. - A change in the fixture directory notifies the front end owners, using CODEOWNERS or a similar mechanism.
For the generated approaches the shape is the same, with a regeneration step: regenerate in CI and fail on a dirty tree, so nobody can forget it.
Choosing
If one developer or one small team owns both ends and ships together, start with golden fixtures and add generation when hand-writing interfaces becomes tedious. If the API serves several clients or is documented externally, make OpenAPI the source. If you already run gRPC or Connect, use the proto files and do not add a second description. Avoid mixing approaches within one API; a single schema with two checking mechanisms is worse than either alone.
Whichever you choose, the rules in the section above on nil slices, large integers and enums do the real work, so write them down once in the repository and enforce them in review. The TypeScript side is documented in the TypeScript handbook, and the JSON behaviours that matter on the Go side are in the encoding/json package documentation.


