Skip to main content

JSON to Go Struct: Generate Typed Structs With json Tags From Any Payload

Turn any JSON payload into Go structs with json tags, exported fields, type inference, nested sub-structs, and slices — then decode it cleanly with encoding/json.

Published By Li Lei
#go #golang #json #encoding-json #struct-tags

JSON to Go Struct: Generate Typed Structs With json Tags From Any Payload

The first time I wired a Go service to a third-party API, I did the thing every Go beginner does: I unmarshalled the response into a map[string]interface{} and reached in with type assertions. data["user"].(map[string]interface{})["id"].(float64). It compiled. It also panicked the first time a field was missing, and the .(float64) quietly told me that JSON numbers decode as float64 into an empty interface — which I learned the hard way at 11pm. Writing the struct by hand would have been the right move, but a payload with 30 fields and three levels of nesting is tedious enough that I kept putting it off.

That tedium is exactly what a struct generator removes. Paste the JSON, get a typed struct, decode it with encoding/json, and let the compiler catch the typo'd keys instead of production. This post walks through how the conversion actually works — the rules that turn a key into a field — and how to feed the result into a clean decode.

The four rules that map JSON onto a struct

A good generator isn't magic; it applies a handful of deterministic rules. Knowing them tells you exactly what output to expect:

  • Each key becomes an exported field. Every JSON key turns into a struct field whose name is capitalized — id becomes ID, created_at becomes CreatedAt. The capitalization isn't cosmetic. In Go, an identifier is only exported (reachable by encoding/json) when it starts with an uppercase letter. A lowercase id field is invisible to json.Unmarshal, which silently skips it and hands you back a zero value. The original key is preserved in a json:"id" tag, so the wire format never changes.
  • Nested objects become nested structs. A {"plan": {"name": "pro"}} payload doesn't collapse into a generic map — it lifts the inner object into its own named struct (say Plan or RootPlan) and the parent field types as that struct. Nesting goes as deep as your JSON does.
  • Arrays become slices. A JSON array maps to a Go slice. "tags": ["a", "b"] becomes Tags []string. An array of objects becomes a slice of a generated struct, e.g. []RootRepo, not []interface{} — so you keep your types all the way down.
  • Numbers and strings are inferred by sample. JSON has one number type, so the generator looks at the value: an integer sample infers int, a fractional one infers float64, and a field that is sometimes whole and sometimes fractional widens to float64 so unmarshalling never fails. Strings stay string, booleans stay bool.

A worked example: small JSON, full struct

Here's the kind of payload you'd get back from a repository endpoint. Drop it into the JSON to Go Struct generator and it parses, infers, and emits in one pass:

{
  "id": 42,
  "full_name": "octocat/hello",
  "private": false,
  "owner": {
    "login": "octocat",
    "html_url": "https://github.com/octocat"
  },
  "topics": ["go", "cli"]
}

The output:

type Root struct {
	ID       int      `json:"id"`
	FullName string   `json:"full_name"`
	Private  bool     `json:"private"`
	Owner    Owner    `json:"owner"`
	Topics   []string `json:"topics"`
}

type Owner struct {
	Login   string `json:"login"`
	HTMLURL string `json:"html_url"`
}

Notice three things. full_name became FullName while its tag kept the snake_case wire name. The nested owner object lifted out into its own Owner struct. And html_url became HTMLURL, not HtmlUrl — known initialisms like ID, URL, HTTP, and API stay all-caps because that's what golint and staticcheck expect. A naive generator that title-cases blindly would give you HtmlUrl, which passes the compiler but fails your linter.

Decoding the payload with encoding/json

Once the struct exists, the decode is three lines. For a response body you already hold in memory:

var repo Root
if err := json.Unmarshal(body, &repo); err != nil {
	return err
}
fmt.Println(repo.Owner.HTMLURL) // https://github.com/octocat

For a stream — an http.Response.Body or an open file — skip the intermediate byte slice and decode directly:

var repo Root
if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
	return err
}

Because every field is exported and tagged, repo.ID, repo.FullName, and repo.Owner.Login are all populated. There are no type assertions, no map[string]interface{}, and the editor autocompletes every field. If the upstream API renames a key, your tag no longer matches and the field comes back zero — a far quieter failure than a panic, and one a test catches immediately.

Struct tags, omitempty, and the round trip

The json:"field" tag does double duty: it controls both how a struct is decoded and how it's encoded. Add ,omitempty and the marshaller drops a field from the output JSON whenever it holds its zero value — 0, "", false, nil, or an empty slice. That's exactly what you want for a compact PATCH body that should only carry the fields you actually changed.

The trap is using omitempty on a field where zero is a real value. A Count int with ,omitempty that you set to 0 simply won't appear in the marshalled JSON, and the receiver can't tell "count is zero" from "count was never sent." When you genuinely need that distinction, reach for a pointer: *int plus omitempty lets nil mean absent and a pointed-to 0 mean a real zero. Pointers also model nullable JSON fields — a value that arrives as null, or one that's present in some array elements and missing in others, maps cleanly to *string.

Where this fits, and a TypeScript cousin

I reach for generated structs in a few recurring spots: typing a third-party API response so the rest of the package works against real fields, generating request and response DTOs from a sample body a teammate sends before either of us writes the handler, and migrating an old map[string]interface{} blob into a compile-checked type. In every case the win is the same — the structure of the data becomes something the Go compiler can verify, instead of something you hope is shaped right at runtime.

If your stack spans the front end too, the same JSON-to-types idea applies on the client side: the JSON to TypeScript interface converter produces matching interfaces so your API contract is typed on both ends. Generate the Go struct for the server, the TypeScript interface for the browser, and the same payload is checked twice.

A struct generator won't write your business logic, but it deletes the most error-prone, least interesting part of consuming JSON in Go. Paste the payload, copy the struct, and spend your evening on the actual problem instead of debugging a missing capital letter.


Made by Toolora · Updated 2026-06-13