Gothic Framework G symbol

Typed JSON: Decode & MapAny

TinyGo can't reliably use encoding/json or reflect. So Gothic generates the decoder at build time: Decode[T] reads your struct's fields and json: tags and turns a Response into a typed value — no reflection, no runtime cost. MapAny is the untyped escape hatch when you just want a quick peek.

The own-BFF pattern. Your API route emits JSON; your WASM client decodes it into the same struct the server marshalled. One type, two importers, zero drift.

Rule that makes or breaks the build: the struct T must live in a net/http-free package. Because Decode[T] pulls T's package into the WASM, TinyGo compiles it — and TinyGo cannot build net/http. So a struct sitting in your api package (whose handlers take http.ResponseWriter) will fail the build. Put shared DTOs in a leaf package like src/shared: struct declarations only.

// src/shared/models.go — a plain, net/http-free package.
// Imported by BOTH your API handler and your WASM page: one source of truth.
package shared

type Address struct {
	City string `json:"city"`
	Zip  string `json:"zip"`
}

type User struct {
	Name     string  `json:"name"`
	Age      int     `json:"age"`
	Username string  `json:"user_name"` // snake_case: key != Go field
	Tags     []string `json:"tags"`
	Address  Address `json:"address"`      // nested struct
}

Now decode into that struct. The type argument is explicit and requiredDecode[shared.User](resp), never Decode(resp). Missing keys and null become the zero value; nested structs, slices, and pointers all work; unknown keys are ignored.

import (
	routes "github.com/gothicframework/core/router"
	. "github.com/gothicframework/core/wasm"
	"github.com/your-org/your-app/src/shared"
)

CreateWasmFunc("loadUser", func() {
	resp, err := Fetch("/api/user/1")
	if err != nil {
		SetText("out", "error: "+err.Error())
		return
	}

	// Decode[T] is a free function (Go forbids generic methods).
	// The type argument is REQUIRED and must be a struct.
	u, err := Decode[shared.User](resp)
	if err != nil {
		SetText("out", "decode error: "+err.Error())
		return
	}

	SetText("name", u.Name)                       // typed field
	SetText("user", u.Username)                   // json:"user_name" rename
	SetText("city", u.Address.City)               // nested struct
	SetText("first-tag", u.Tags[0])               // slice element
	SetText("age", strconv.Itoa(u.Age))
})

No struct handy? resp.MapAny() parses an object into a map[string]any. Note: numbers decode as float64 (an int64 above 2^53 loses precision), so cast accordingly.

CreateWasmFunc("peek", func() {
	resp, err := Fetch("/api/user/1")
	if err != nil {
		return
	}

	// MapAny is untyped: JSON object -> map[string]any. Numbers are float64.
	// Great for a quick read when you don't have (or don't want) a struct.
	m, err := resp.MapAny()
	if err != nil {
		return
	}
	if name, ok := m["name"].(string); ok {
		SetText("name", name)
	}
	if age, ok := m["age"].(float64); ok {
		SetText("age", strconv.Itoa(int(age)))
	}
})

Decoding is only half the story. Want to send a typed struct as a request body? Learn Encode next!