Skip to content
gohacks.dev
APIs

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.

10 min read
Summarize in AI
Flat illustration of an editor window full of code, with the TypeScript logo.

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.

types.goGo
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:

tsgen.goGo
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.Time becomes string. encoding/json writes RFC 3339 text, and the generator checks for the type before looking at Kind because a time.Time is also a struct.
  • json.RawMessage becomes unknown. It is a []byte underneath, and my first draft sent it down the byte-slice path and produced string. The slice check has to come after the specific type check.
  • Pointers become T | null, since a nil pointer marshals as null.
  • []byte becomes string, 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 ,string option, where they become string.
  • Everything else is unknown, an honest answer for any.

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.

usage.tsTypeScript
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);
main.goGo
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{}))
}
Shell
go run . > api.ts
cat api.ts
tsc --noEmit --strict api.ts usage.ts && echo "usage.ts: type check passed"
go run . json
Text
// 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.

bad.tsTypeScript
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);
Shell
tsc --noEmit --strict api.ts bad.ts
Text
bad.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 generate directive 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 diff is not empty. This catches the person who changed a struct and forgot.
  • A Go test that calls Generate and 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.

Also inWeb
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.