Generate TypeScript Types From Go Structs Automatically
Write a small reflection based generator that turns Go structs into TypeScript interfaces, handling json tags, omitempty, nil slices, int64 and enums.

A TypeScript front end and a Go back end talk in JSON, and both sides keep a private idea of what that JSON looks like. When a Go struct gains a field or changes a tag, the TypeScript type stays as it was until a user hits a runtime error. The cheap fix is to generate the TypeScript from the Go types, so the front end's compiler complains the moment they drift.
Tested with Go 1.25.6, TypeScript 5.3.3, macOS on Apple Silicon (arm64)
Mature tools exist for this, and I will name them at the end. Writing a small generator once is worthwhile for a different reason: the rules for mapping Go to TypeScript are the interesting part, and they are the same rules every tool has to implement. Get them wrong and you get types that compile and lie. This article builds a generator in about ninety lines using reflect, runs it, and checks the output against real JSON and a real TypeScript compiler. If you are still deciding whether to generate types at all, sharing types between Go and TypeScript compares the approaches. The generator relies on how struct tags and JSON encoding behave. It ran on Go 1.25.6 and TypeScript 5.3.3.
The Go types to describe
A ticket with the field kinds that cause trouble: an embedded struct, a pointer, slices that may be nil, an optional map, an int64, a string-typed enum and a raw JSON member.
package main
import (
"encoding/json"
"time"
)
type Status string
const (
StatusOpen Status = "open"
StatusClosed Status = "closed"
)
type Address struct {
Street string `json:"street"`
City string `json:"city"`
}
type Timestamps struct {
CreatedAt time.Time `json:"created_at"`
ClosedAt *time.Time `json:"closed_at"`
}
type Ticket struct {
Timestamps // embedded: its fields appear at the top level of the JSON
ID int64 `json:"id,string"` // int64 as a string keeps precision in JavaScript
Title string `json:"title"`
Status Status `json:"status"`
Priority int `json:"priority"`
Assignee *string `json:"assignee"`
Labels []string `json:"labels"`
Notes []string `json:"notes,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
Address *Address `json:"address,omitempty"`
Extra json.RawMessage `json:"extra,omitempty"`
Secret string `json:"-"`
internal int
}Secret and internal must not appear in the output. ID carries ,string, which tells encoding/json to write the number as a JSON string. That tag exists for exactly the problem the generator has to respect: JavaScript numbers are 64-bit floats and silently lose digits above 2^53, so an int64 identifier sent as a number can arrive changed.
The generator
The core is a function from reflect.Type to a TypeScript expression, plus a pass over struct fields:
package main
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
)
type generator struct {
out []string
seen map[reflect.Type]bool
enums map[reflect.Type][]string
}
var (
timeType = reflect.TypeOf(time.Time{})
rawMessageType = reflect.TypeOf(json.RawMessage(nil))
)
// Generate returns TypeScript declarations for the given Go types and every named struct they reach.
func Generate(enums map[reflect.Type][]string, roots ...any) string {
g := &generator{seen: map[reflect.Type]bool{}, enums: enums}
for _, r := range roots {
g.ref(reflect.TypeOf(r))
}
return "// Code generated by tsgen. DO NOT EDIT.\n\n" + strings.Join(g.out, "\n\n") + "\n"
}
// ref returns the TypeScript expression for t, declaring named structs on the way.
func (g *generator) ref(t reflect.Type) string {
switch {
case t == timeType:
return "string" // encoding/json writes RFC 3339 text
case t == rawMessageType:
return "unknown" // json.RawMessage is a []byte, but it holds whatever JSON the caller stored
case t.Kind() == reflect.Pointer:
return g.ref(t.Elem()) + " | null"
case t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8:
return "string" // []byte is base64 text
case t.Kind() == reflect.Slice, t.Kind() == reflect.Array:
inner := g.ref(t.Elem())
if strings.Contains(inner, " ") {
inner = "(" + inner + ")"
}
return inner + "[]"
case t.Kind() == reflect.Map:
return "Record<string, " + g.ref(t.Elem()) + ">"
case t.Kind() == reflect.Struct:
g.declare(t)
return t.Name()
case t.Kind() == reflect.String:
if vals, ok := g.enums[t]; ok {
quoted := make([]string, len(vals))
for i, v := range vals {
quoted[i] = fmt.Sprintf("%q", v)
}
return strings.Join(quoted, " | ")
}
return "string"
case t.Kind() == reflect.Bool:
return "boolean"
case t.Kind() >= reflect.Int && t.Kind() <= reflect.Float64:
return "number"
}
return "unknown" // interface{}, json.RawMessage and anything else the encoder decides at run time
}
func (g *generator) declare(t reflect.Type) {
if g.seen[t] {
return
}
g.seen[t] = true
var lines []string
g.fields(t, &lines)
g.out = append(g.out, fmt.Sprintf("export interface %s {\n%s\n}", t.Name(), strings.Join(lines, "\n")))
}
func (g *generator) fields(t reflect.Type, lines *[]string) {
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
tag := f.Tag.Get("json")
if !f.IsExported() && !f.Anonymous || tag == "-" {
continue
}
name, opts, _ := strings.Cut(tag, ",")
if f.Anonymous && name == "" && f.Type.Kind() == reflect.Struct {
g.fields(f.Type, lines) // embedded structs are flattened by encoding/json
continue
}
if name == "" {
name = f.Name
}
optional := strings.Contains(opts, "omitempty") || strings.Contains(opts, "omitzero")
expr := g.ref(f.Type)
if optional && f.Type.Kind() == reflect.Pointer {
expr = strings.TrimSuffix(expr, " | null") // omitempty drops a nil pointer, so the key is absent, never null
}
if strings.Contains(opts, "string") && f.Type.Kind() >= reflect.Int && f.Type.Kind() <= reflect.Float64 {
expr = "string"
}
if f.Type.Kind() == reflect.Slice && f.Type.Elem().Kind() != reflect.Uint8 && !optional {
expr += " | null" // a nil slice marshals as null, not []
}
mark := ""
if optional {
mark = "?"
}
*lines = append(*lines, fmt.Sprintf(" %s%s: %s;", name, mark, expr))
}
}The rules, in the order the code applies them:
time.Timebecomesstring.encoding/jsonwrites RFC 3339 text, and the generator checks for the type before looking atKindbecause atime.Timeis also a struct.json.RawMessagebecomesunknown. It is a[]byteunderneath, and my first draft sent it down the byte-slice path and producedstring. The slice check has to come after the specific type check.- Pointers become
T | null, since a nil pointer marshals asnull. []bytebecomesstring, because the encoder writes base64 text.- Other slices and arrays become
T[], parenthesised when the element is a union. - Maps become
Record<string, T>. JSON object keys are always strings. - Structs become named interfaces, declared once each.
- String-kinded types with registered values become a union of literals. Go has no way to enumerate constants through reflection, so the enum table is explicit.
- Numbers become
number, except with the,stringoption, where they becomestring. - Everything else is
unknown, an honest answer forany.
Field handling adds three more. A field tagged json:"-" and unexported fields are skipped. Embedded structs without a tag name are flattened into the parent, matching what encoding/json does. omitempty and omitzero mark a property optional with ?.
One case took a second look. For an omitempty pointer, a nil value is left out entirely and never serialised as null, so the right type is address?: Address. The first version printed address?: Address | null, which is permissive in a way that hides bugs, because TypeScript then lets code write null into a field the server would treat as present.
Running it and checking the result
The program prints the declarations. A short usage.ts builds a ticket from the generated types, and the shell block compiles it with the strictest checking. The last command asks Go for the JSON of a ticket with no labels so the types can be compared with the wire.
import type { Ticket } from "./api";
const t: Ticket = {
created_at: "2026-01-01T10:00:00Z",
closed_at: null,
id: "9007199254740993",
title: "Printer on fire",
status: "open",
priority: 1,
assignee: null,
labels: ["hardware"],
};
const first: string = t.labels === null ? "none" : t.labels[0];
console.log(first, t.status);package main
import (
"encoding/json"
"fmt"
"os"
"reflect"
"time"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "json" {
// What the wire really carries for a ticket nobody has labelled.
t := Ticket{ID: 9007199254740993, Title: "Printer on fire", Status: StatusOpen, Priority: 1}
t.CreatedAt = time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
b, _ := json.Marshal(t)
fmt.Println(string(b))
return
}
enums := map[reflect.Type][]string{
reflect.TypeOf(Status("")): {string(StatusOpen), string(StatusClosed)},
}
fmt.Print(Generate(enums, Ticket{}))
}go run . > api.ts
cat api.ts
tsc --noEmit --strict api.ts usage.ts && echo "usage.ts: type check passed"
go run . json// Code generated by tsgen. DO NOT EDIT.
export interface Address {
street: string;
city: string;
}
export interface Ticket {
created_at: string;
closed_at: string | null;
id: string;
title: string;
status: "open" | "closed";
priority: number;
assignee: string | null;
labels: string[] | null;
notes?: string[];
meta?: Record<string, string>;
address?: Address;
extra?: unknown;
}
usage.ts: type check passed
{"created_at":"2026-01-01T10:00:00Z","closed_at":null,"id":"9007199254740993","title":"Printer on fire","status":"open","priority":1,"assignee":null,"labels":null}Look at the last line. The nil Labels slice came out as "labels":null, not []. This is the single most common surprise when typing Go APIs, and the generator handles it by emitting labels: string[] | null. The alternative, string[], would pass the compiler and crash t.labels.length in the browser.
You have three honest choices here, and they are not equivalent. Emit | null and make the front end check, as above. Change the Go code to initialise slices (Labels: []string{}) and keep the type as string[], which is simpler for consumers but needs discipline on every code path that builds a ticket. Or add omitempty and accept an optional property. Pick one rule per project, write it down, and let the generator reflect the choice. Go 1.24 added omitzero, which is useful for time.Time and similar.
The id is a string for the same reason as above: the JSON carries "id":"9007199254740993", and a JavaScript number could not hold that value exactly.
Proving the types catch mistakes
A type that nothing can violate is worthless, so here is a second file with three deliberate errors. It uses a number for the id, a status that is not in the enum, and reads .length of a nullable array.
import type { Ticket } from "./api";
const t: Ticket = {
created_at: "2026-01-01T10:00:00Z",
closed_at: null,
id: 9007199254740993,
title: "Printer on fire",
status: "pending",
priority: 1,
assignee: null,
labels: ["hardware"],
};
console.log(t.labels.length);tsc --noEmit --strict api.ts bad.tsbad.ts(6,3): error TS2322: Type 'number' is not assignable to type 'string'.
bad.ts(8,3): error TS2322: Type '"pending"' is not assignable to type '"closed" | "open"'.
bad.ts(13,13): error TS18047: 't.labels' is possibly 'null'.The compiler rejects all three, each at the line where it happens. Notice the status error lists the allowed values, which is where the enum table pays off.
Wiring it into a project
Three ways to run the generator:
- A
go generatedirective next to the types://go:generate go run ./cmd/tsgen -out ../web/src/api.ts. Developers regenerate when they change a struct. - A CI check that regenerates and fails when
git diffis not empty. This catches the person who changed a struct and forgot. - A Go test that calls
Generateand compares with the committed file. It is the cheapest form of the same idea and runs with the rest of your tests.
Register the roots explicitly, as the lab does: list the request and response types that make up the API, and let the generator follow references. Generating every struct in the repository leaks internal types into the front end's bundle.
What this generator does not do
It does not know about custom MarshalJSON methods. A type with its own marshaller can emit anything, and reflection cannot see it, so such types need an entry in a table of overrides. It does not handle generics. It does not emit validation, so the types say what a response looks like and prove nothing about whether a server actually sent it. For that, check responses at the boundary with a schema validator or use a contract as in OpenAPI contracts shared between services and clients.
Before you maintain your own, look at the existing tools. tygo generates TypeScript from Go packages by reading source, so it can carry comments and constants, and typescriptify-golang-structs works through reflection like this one. OpenAPI generators go through a schema instead of the Go source directly. The mapping rules above are what to check in whichever you pick. The standard library's encoding/json documentation defines the behaviours the generator mirrors, and the TypeScript handbook covers the union and optional property syntax used in the output.


