Documentation
¶
Overview ¶
Command tsgen renders the Go response structs the web UI consumes into TypeScript, so the types the console is written against are generated from the structs the server marshals instead of being mirrored by hand.
The alternative — a hand-written copy in web/src/types — drifts silently: a Go field rename does not fail tsc, it ships a UI reading `undefined`. With this tool the Go side is the only source of truth, and the CI gate (`make check-ts`, part of `make docs-check`) fails the build when the checked-in web/src/types/api.gen.ts no longer matches the Go source.
Usage:
go run ./cmd/tsgen --write # regenerate web/src/types/api.gen.ts go run ./cmd/tsgen --check # exit 1 with a "run make generate-ts" message on drift go run ./cmd/tsgen --workspace . # workspace root (default: the directory containing go.mod)
What gets generated is exactly the manifest below — the structs the web UI actually reads — never "every type with a json tag". Adding a type is one manifest line; the renderer refuses (with the field named) to reference a type the manifest does not list, so the set cannot grow by accident.
Rendering rules, which together define the TypeScript contract:
- A struct becomes `export interface`; its fields are named by their `json` tag (falling back to the Go name, as encoding/json does), in source order. Unexported fields and `json:"-"` fields are skipped.
- `omitempty` or `omitzero` makes the field optional (`name?:`). A pointer field WITHOUT either renders as `T | null`, because that is what a nil pointer marshals to. Nil slices and maps also marshal as null, but the generator deliberately renders them as plain arrays/records: every handler in the manifest sends empty collections rather than nil (several say so in their comments), and `| null` on every list would cost every consumer a guard for a value the server never sends.
- Numbers (all int/uint/float widths) → `number`; `string` → `string`; `bool` → `boolean`; `time.Time` → `string` (RFC 3339); `[]byte` → `string` (base64); `json.RawMessage`, `any` → `unknown`; `[]T` → `T[]`; `map[string]V` → `Record<string, V>`; a `json:",string"` option → `string`.
- A type whose underlying type is `string` (alias or defined) becomes a union of the string constants declared with that type in the same package, in declaration order — `type EmulationTier = string` plus `TierFull EmulationTier = "full"` renders as `"full" | …`. With no such constants it is `string`. An empty-string constant (the type's zero value, which omitempty elides) is not a union member.
- `net/http.Header` → `Record<string, string[]>`.
- Go doc comments on the type and on each field (including a trailing line comment) are carried over verbatim as JSDoc, so the reasoning that lives on the Go side is visible from the editor on the TypeScript side.
Embedded fields, anonymous struct fields, generics and non-string map keys are rejected rather than guessed at — none of the consumed structs use them, and an error names the field so the rule can be added deliberately when one does. A type with its own MarshalJSON is rejected too: its wire shape is whatever that method writes, so the manifest must name the package-level struct the method encodes (trace.Entry → trace.entryJSON is the pattern).
Files are read through go/build pinned to linux/amd64 (the platform the shipped binary runs on), so the output does not depend on the host that regenerated it.