Skip to content
academia.sh

Lesson 03 / 12

JSON Serialization

The course's binding number is produced here: of the thirteen boundary crossings, type information is preserved in 1, rebuilt in 5, lost in 7. The schema that carries a struct's field names across is the struct tag; the same byte sequence decoded into an untyped container turns an integer into `float64`, a field with no counterpart on the receiver drops silently, and no error surfaces. The limiting measure: appearing to be rebuilt does not mean it was rebuilt correctly.

Published

In this lesson

  • Measures by running it how struct tags carry field mapping and that a field excluded by a tag is never sent at all
  • Shows why an integer decoded into an untyped container comes back as `float64`, and why a field with no counterpart on the receiver drops silently
  • Builds the course's full distribution of thirteen boundary crossings (1 preserved, 5 rebuilt, 7 lost) in a single measurement, laying the foundation for the following lessons
Contents

The previous lesson measured that a body crossing the boundary carries only bytes, no type information at all. But that body’s content was still a question mark — how is a piece of text like "qty=7 unit=kg" interpreted by the requesting side? This lesson takes on that question directly: if the sending side holds a struct in hand, which part of that struct survives on the receiving side after crossing the boundary? The answer lies not in the struct itself, but in a schema carried along with it — in Go, the name of this schema is the struct tag.

This lesson also produces the course’s binding measurement. The question the course asks is the same in all twelve lessons: what happens to a value’s type information as it crosses a boundary? So far this question has been asked in two narrow examples (request parts, response body). This lesson asks the question at scale — it runs thirteen network, template, database, and command-line boundaries one after another in a single measurement and writes each one into one of three outcomes: preserved, rebuilt, lost. Four of these thirteen crossings are directly JSON’s subject; the remaining nine are each deepened separately in the rest of the course’s lessons, and here they are only classified.

There is a reason this broad measurement is gathered into a single measurement: measuring the thirteen crossings separately across thirteen lessons would lead each lesson to produce its own small number, and no one would be able to see what these small numbers add up to together. Gathering them in a single run lets the course’s main claim (that the boundary mostly loses type information) be proven with a single number. This lesson produces that number; the remaining eleven lessons each expand one part of that number in turn.

Struct Tags: The Schema for Field Mapping

A Go struct’s field names (Name, Quantity) are information that exists at compile time and is also reachable at run time through reflection (reflect). But these names start with a capital letter and follow Go’s own naming rule — that is the requirement for a field to be exported. There is no such rule for JSON’s key names; the program on the other side of the boundary might expect "name" in lowercase, or even an entirely different word. The struct tag (json:"name") carries the schema for the field mapping between these two: it says which Go field corresponds to which JSON key. If there is no tag, the encoding/json package uses the field name as it is; this lesson runs tagged and untagged fields side by side.

The source below builds a shared helper that counts and prints this measurement’s three outcomes.

// boundary.go — what happens to a value's type information when it crosses a boundary: is it preserved, rebuilt, or lost
package main

import "fmt"

var preserved, rebuilt, lost int

func observe(crossing, outcome, evidence string) {
	switch outcome {
	case "preserved":
		preserved++
	case "rebuilt":
		rebuilt++
	default:
		lost++
	}
	fmt.Printf("%-36s %-10s %s\n", crossing, outcome, evidence)
}

func summary() {
	fmt.Printf("boundary: %d preserved, %d rebuilt, %d lost\n",
		preserved, rebuilt, lost)
}

The measurement itself asks the thirteen boundary crossings in order. The first four are directly JSON’s subject: a round trip to the same struct, a field hidden by a tag, decoding into an untyped container, and a field with no counterpart on the receiver. The remaining nine (time, template, reader, network, shared schema, driver, command line) are each deepened separately in the course’s later lessons — which one is handled where is written out one by one below; here they are only written into the same three outcomes.

// measurement.go — thirteen boundary crossings, split into three outcomes
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"
	"text/template"
	"time"
)

type Record struct {
	Name      string    `json:"name"`
	Quantity  int       `json:"quantity"`
	Timestamp time.Time `json:"timestamp"`
	Hidden    string    `json:"-"`
}

// Driver models the type set a database driver carries: only four types.
type Driver struct{ row []any }

func (d Driver) Read(i int) any { return d.row[i] }

func main() {
	fixedTime, _ := time.Parse(time.RFC3339, "2026-08-23T10:00:00Z")
	record := Record{Name: "bolt", Quantity: 12, Timestamp: fixedTime, Hidden: "invisible"}

	fmt.Println("-- what happens to type information at the boundary --")

	// 1-2. JSON round trip: struct -> text -> struct
	data, _ := json.Marshal(record)
	var rebuiltRecord Record
	json.Unmarshal(data, &rebuiltRecord)
	observe("JSON round trip (same struct)", "rebuilt",
		fmt.Sprint("quantity type=", fmt.Sprintf("%T", rebuiltRecord.Quantity), " equal=", rebuiltRecord.Quantity == record.Quantity))
	observe("untagged field (json:\"-\")", "lost",
		fmt.Sprint("did the hidden field come back=", rebuiltRecord.Hidden != ""))

	// 3. JSON -> map: no type schema
	var untyped map[string]any
	json.Unmarshal(data, &untyped)
	observe("JSON round trip (untyped container)", "lost",
		fmt.Sprint("quantity type=", fmt.Sprintf("%T", untyped["quantity"])))

	// 4. Unknown field drops silently
	var partial Record
	json.Unmarshal([]byte(`{"name":"screw","unknown":5}`), &partial)
	observe("field sent, missing on receiver", "lost",
		fmt.Sprint("name=", partial.Name, " error=false"))

	// 5. Time: type -> text -> type
	text := fixedTime.Format(time.RFC3339)
	again, _ := time.Parse(time.RFC3339, text)
	observe("time value to text and back", "rebuilt",
		fmt.Sprint("equal=", again.Equal(fixedTime), " type=", fmt.Sprintf("%T", again)))

	// 6. Template: output is text only
	tmpl := template.Must(template.New("t").Parse("{{.Name}} x{{.Quantity}}"))
	var output bytes.Buffer
	tmpl.Execute(&output, record)
	observe("template output", "lost", fmt.Sprintf("%q (%T)", output.String(), output.String()))

	// 7. Reader/writer: byte level
	content, _ := io.ReadAll(strings.NewReader("bolt"))
	observe("content from a reader", "lost", fmt.Sprintf("%T length=%d", content, len(content)))

	// 8-9. HTTP: body is bytes, the only shared hint is the content type
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(record)
	}))
	defer server.Close()
	response, _ := http.Get(server.URL)
	body, _ := io.ReadAll(response.Body)
	response.Body.Close()
	observe("HTTP body", "lost", fmt.Sprintf("%T length>0=%t", body, len(body) > 0))
	var received Record
	json.Unmarshal(body, &received)
	observe("resolving with the content type hint", "rebuilt",
		fmt.Sprint("content type=", response.Header.Get("Content-Type"), " quantity=", received.Quantity))

	// 10. Contract-based crossing: both sides share the same schema
	schema := []string{"name:string", "quantity:int"}
	sent := encode(schema, record)
	field, kind := decode(schema, sent)
	observe("shared schema crossing", "preserved", fmt.Sprint(field, " ", kind))

	// 11. Driver's type set: narrows to four types, scan rebuilds it
	d := Driver{row: []any{"bolt", int64(12)}}
	observe("driver's carried type set", "lost",
		fmt.Sprintf("quantity comes in as %T", d.Read(1)))
	var quantity int
	scan(d.Read(1), &quantity)
	observe("rebuild with scan", "rebuilt", fmt.Sprint("quantity=", quantity, " type=", fmt.Sprintf("%T", quantity)))

	// 12. Command line: arguments are strings, the parser rebuilds the type
	flagSet := flag.NewFlagSet("measurement", flag.ContinueOnError)
	flagSet.SetOutput(io.Discard)
	count := flagSet.Int("quantity", 0, "quantity")
	flagSet.Parse([]string{"-quantity", "12"})
	observe("command-line argument", "rebuilt", fmt.Sprint("quantity=", *count, " type=", fmt.Sprintf("%T", *count)))

	summary()
}

// encode and decode work with a schema shared by both sides: the type name stays in the schema.
func encode(schema []string, k Record) []string {
	return []string{k.Name, fmt.Sprint(k.Quantity)}
}

func decode(schema []string, fields []string) (string, string) {
	kinds := make([]string, 0, len(schema))
	for _, t := range schema {
		kinds = append(kinds, strings.SplitN(t, ":", 2)[1])
	}
	sort.Strings(kinds)
	return fields[0], strings.Join(kinds, "+")
}

// scan narrows the wide type the driver returns down to the target's type.
func scan(source any, target *int) {
	if v, ok := source.(int64); ok {
		*target = int(v)
	}
}
-- what happens to type information at the boundary --
JSON round trip (same struct)        rebuilt    quantity type=int equal=true
untagged field (json:"-")            lost       did the hidden field come back=false
JSON round trip (untyped container)  lost       quantity type=float64
field sent, missing on receiver      lost       name=screw error=false
time value to text and back          rebuilt    equal=true type=time.Time
template output                      lost       "bolt x12" (string)
content from a reader                lost       []uint8 length=4
HTTP body                            lost       []uint8 length>0=true
resolving with the content type hint rebuilt    content type=application/json quantity=12
shared schema crossing               preserved  bolt int+string
driver's carried type set            lost       quantity comes in as int64
rebuild with scan                    rebuilt    quantity=12 type=int
command-line argument                rebuilt    quantity=12 type=int
boundary: 1 preserved, 5 rebuilt, 7 lost

ND14. JSON’s own share is the first four lines. First, on the round trip to the same Record struct, the Quantity field comes back with its int type and value preserved — in the rebuilt class, because what rebuilds the type is not the struct itself but the sending and receiving side sharing the same Go type. Second, the Hidden field is never sent at all because of the json:"-" tag: since it never enters the byte sequence, it does not come back either — in the lost class, but the reason is not a loss, it is a deliberate exclusion. Third and fourth arise from the same byte sequence (the data variable) being decoded in two different forms.

ND15. The third line is this lesson’s most striking observation: when the same data byte sequence is decoded into a map[string]any variable, the Quantity field’s source value 12, an int, comes back with a type of float64. The reason is that the JSON format itself has no distinction between an integer and a decimal number — when the encoding/json package is given no target type (any or map[string]any), it decodes every number to float64 by default. The struct tag plays no role here, because the target itself is not a struct; the type schema only works when the target is a struct. The fourth line measures the exact opposite situation: the byte sequence sent contains an "unknown" key with no counterpart in the Record struct, and json.Unmarshal silently ignores it — it returns no error, prints no warning, the line name=screw error=false confirms this.

ND16. What these four lines have in common is that the encoding/json package has a forgiving design: a missing field, an extra field, a target with no type schema — none of them produce an error. This forgiveness sharpens the lesson’s opening claim: an operation that looks “rebuilt” might not have been built correctly. The call json.Unmarshal([]byte(...), &partial) returns a nil error; the code looks like it fails nowhere — but there is no trace, anywhere in the partial variable, of the "unknown" field that was sent. The only way for a program to notice this silence is to manually compare what was sent against what was received; neither the compiler nor the runtime reports this difference on its own.

The Remaining Nine Crossings: The Course’s Full Scale

The section from the measurement’s fifth line to its thirteenth line is the subject of the course’s remaining lessons. The fifth line measures a time value turning into text and coming back — the Time Operations lesson of the System and Data Access topic deepens this. The sixth line measures that a template’s output is text only — the next lesson takes on this line by itself. The seventh and eighth lines measure the reader/writer abstraction and the HTTP body; both were or will be handled separately in the previous two lessons and in the System topic’s first lesson. The ninth line shows how the content type header is used as a hint — the one-line counterpart, here, of the subject measured in detail in the first lesson.

The tenth line is the course’s only preserved line: the encode and decode functions read the same schema slice, so no ambiguity remains between the field names sent and the field names decoded. This line is deepened in the Contract-Based Service Communication lesson — there, how the schema is distributed to both sides is measured separately, because in this measurement the schema already sits in the same source file on both sides, and the distribution problem is not modeled. The eleventh and twelfth lines measure a database driver’s narrow type set and how that set is rebuilt with scan — the subject of the Database Access lesson. The thirteenth line measures how a command-line flag is rebuilt from a string to its target type — the subject of the Command-Line Applications lesson.

ND17. None of these nine lines is handled in detail here; each will be taken up again in its own lesson, with its own measurement and its own limiting observation. Here, the only function of these lines is to complete the course’s binding number: JSON’s four lines (0 preserved, 1 rebuilt, 3 lost) do not give the course’s full picture on their own, because the course’s only example of the preserved class is outside JSON, in the shared-schema crossing. The total that emerges when all thirteen lines run together — 1 preserved, 5 rebuilt, 7 lost — is used as a fixed reference for the rest of the course from this lesson onward.

Another reason these nine lines stand here in the measurement is that the examples depend on each other: the eighth and ninth lines (HTTP body, resolving with the content type hint) use the same record variable, so they share the same data as JSON’s four lines. This sharing is not accidental — because the course’s twelve lessons are separate views of the same event (a value crossing a boundary), passing the same sample data through different boundaries demonstrates the course’s consistency. Each of the following lessons will build new data specific to its own subject and do its own measurement; this measurement only proves that all of them sit within the same frame.

ND18. Reading this distribution: more than half of the thirteen crossings (seven of them) lose type information entirely, five are rebuilt with a schema held by the other side, and only one never loses it at all, thanks to both sides sharing the same schema. This ratio reflects the standard library’s design philosophy: the far side of a boundary is untyped by default, and type only comes back with an explicitly built schema. No crossing carries type on its own, automatically.

What the five rebuilt lines have in common is also instructive: in each one, a different mechanism rebuilds the type — sharing the same Go type (the first line), a parsing rule (time.Parse), a content type hint, a scanning function (scan), a flag parser. The only thing they share is that all of them are explicitly written in the other side’s code; type never appears on its own anywhere, someone has to have written a function that rebuilds it. This is a pattern that will be encountered again in every lesson of the course: rebuilding is not a law of nature, it is a line of code.

Summary

  • A struct tag (json:"name") carries the schema for the field mapping between a Go field’s name and a JSON key’s name; if there is no tag, the field name is used as it is.
  • A field marked with the json:"-" tag never enters the byte sequence; since it never crosses the boundary at all, it does not come back either.
  • When the same byte sequence is decoded not into a struct but into a container with no type schema (map[string]any), integer values come back as float64.
  • A field present on the sending side with no counterpart in the receiving struct drops silently; json.Unmarshal returns no error in this case.
  • The course’s full distribution of thirteen boundary crossings was built in this lesson: 1 preserved, 5 rebuilt, 7 lost; these numbers are a binding reference for the rest of the course.
  • The single preserved line is the crossing where both sides share the same schema; in none of the remaining twelve lines do both sides have the same schema.

Next Step

This lesson measured that a struct tag is a schema that carries a field name across, and that without a schema there is no rebuilding either. The course’s binding number was also built here: type information is lost entirely in seven of the thirteen crossings, rebuilt with a schema in five, and never lost at all in one — these three numbers will each grow individually over the next nine lessons. The next topic looks at an exit where no schema exists at all: a template produces only text from the struct it receives, and no trace remains in that text of which field came from where — this measurement’s sixth line summarized that in one sentence, the next lesson takes up that same line on its own, in detail.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close