Go Anonymous Structs — Middle Level¶
1. Introduction¶
At the middle level, anonymous structs are a deliberate design tool: you choose them when a shape is truly local to one function or test, and you choose a named type the moment that shape escapes the function or grows past three or four fields. You also know the subtle parts of structural identity (tag matters, order matters), the limits in public APIs, and the trade-offs against named types.
2. Prerequisites¶
- Junior-level material (this topic)
- Named structs (2.3.5)
- Type identity rules (2.4)
- JSON marshaling
- Table-driven tests
- HTTP handlers
3. Glossary¶
| Term | Definition |
|---|---|
| Anonymous struct | Struct value declared inline; type has no name |
| Structural identity | Two anonymous structs are the same type iff fields match exactly |
| Field tag | Backtick-quoted string after a field; part of the type identity |
| One-off | Used once; not promoted to a named type |
| DTO | Data Transfer Object — a shape used at an API boundary |
| Composite literal | A T{...} value-construction expression |
| Promoted shape | A previously anonymous struct lifted into a named type |
4. Core Concepts¶
4.1 Anonymous Structs as a Locality Statement¶
Choosing an anonymous struct says: "this shape is only used here, and naming it would not pay for itself." When that statement is true, anonymous structs reduce the number of named types in the package and keep the shape next to the code that uses it.
When the statement stops being true — the shape is used in two functions, or imported from another package — switch to a named type.
4.2 Structural Identity Rules (Detailed)¶
Two struct types (named or anonymous) are the same type iff: - They have the same number of fields, in the same order. - Each field has the same name (or both are anonymous fields). - Each field has the same type (recursively, by identity). - Each field has the same tag string (byte-for-byte). - Each field has the same export status (uppercase first letter).
Anonymous structs benefit from this because two struct{X int} literals in the same package give you the same type. As soon as one literal adds a tag, swaps a field, or reorders fields, the types diverge.
// Same type
type T1 = struct{ A int }
type T2 = struct{ A int }
// Different — tag differs
type T3 = struct{ A int `json:"a"` }
type T4 = struct{ A int }
// Different — field order
type T5 = struct{ A, B int }
type T6 = struct{ B, A int }
4.3 Why Methods Are Not Allowed¶
A method declaration func (r ReceiverType) M(...) {...} requires a single named receiver type defined in the same package. The grammar does not accept a struct literal as a receiver type. So an anonymous struct cannot have methods, and therefore cannot satisfy any interface that demands methods.
The only interface an anonymous struct can satisfy is interface{} (or any).
4.4 Anonymous Structs in Function Signatures¶
Legal but rude:
// Caller has to spell the entire shape:
func describe(p struct{ X, Y int }) string { ... }
describe(struct{ X, Y int }{1, 2}) // verbose
Two reasons to avoid: 1. The caller cannot use a different (but identical-looking) type from another package. 2. Documentation tools have nothing to link to.
4.5 Table-Driven Tests as the Killer App¶
cases := []struct {
name string
input string
wantTokens int
wantErr bool
}{
{"empty", "", 0, false},
{"one", "a", 1, false},
{"bad", "\xff", 0, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) { ... })
}
The shape is local. It changes test by test. It does not deserve a name.
4.6 Inline JSON Shapes in HTTP Handlers¶
func health(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
Build string `json:"build"`
}{
Status: "ok",
Build: buildID,
})
}
The response shape exists only here. Naming it would pollute the package.
4.7 Trade-Offs Against Named Types¶
| Concern | Anonymous wins | Named wins |
|---|---|---|
| One-off use | yes | — |
| Shared between functions | — | yes |
| Cross-package | — | yes |
| Need methods | — | yes |
| Need to be referenced in docs | — | yes |
| Compact tests | yes | — |
| Stable wire schema | — | yes |
| Tag-driven serialization | both | both |
The decision is almost always: "named, unless the shape is local AND simple AND has no methods."
4.8 When NOT to Use¶
- gRPC service definitions.
- Persisted database models.
- Wire formats shared across services.
- Anywhere methods are required.
- Public function signatures.
- Library APIs.
4.9 Embedding¶
Anonymous structs can appear as a named field in a named struct (legal), but they cannot be used as an anonymous (embedded) field because anonymous fields must be type names.
// OK — named field, anonymous type
type Outer struct {
Meta struct{ ID int }
}
// NOT OK — anonymous field cannot be a struct literal
type Bad struct {
struct{ ID int } // syntax error
}
5. Real-World Analogies¶
A scratch pad on a desk: it lives where you work, has labels for the columns, and is recycled. Naming it would imply you plan to refile and reuse it.
An ad-hoc spreadsheet sent over chat: shape, content, single use, and gone. If two teammates start sending the same shape every week, you create a named template.
A custom paper form at a clinic: filled, scanned, archived, never reused. If the form starts showing up in every visit, the clinic prints a named version.
6. Mental Models¶
Model 1 — Locality¶
Model 2 — Identity¶
Model 3 — Refactor Threshold¶
7. Pros & Cons¶
Pros¶
- No type-name pollution.
- Shape stays next to the code that uses it.
- Same memory layout and runtime cost as named structs.
- Compact, idiomatic in tests.
- Easy to compose for one-off serialization.
Cons¶
- No methods.
- Awkward in public APIs.
- Easy to drift out of sync if accidentally duplicated.
- No central place to document.
- Tag differences silently break identity.
8. Use Cases¶
- Table-driven tests.
- One-off JSON request/response shapes.
- Logging and metrics payloads in a single handler.
- Small return bundles from private helpers.
- Temporary value grouping during refactors.
- Configuration shapes used by a single helper.
9. Code Examples¶
Example 1 — Test Table With Subtests¶
package strings_test
import (
"strings"
"testing"
)
func TestToUpper(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"ascii", "go", "GO"},
{"mixed", "GoLang", "GOLANG"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := strings.ToUpper(c.in); got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
Example 2 — Inline Request Body¶
package main
import (
"bytes"
"encoding/json"
"net/http"
)
func sendEvent(client *http.Client, name string, count int) error {
body, _ := json.Marshal(struct {
Name string `json:"name"`
Count int `json:"count"`
}{Name: name, Count: count})
resp, err := client.Post("https://api.example.com/events",
"application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
Example 3 — Inline Response Decode¶
package main
import (
"encoding/json"
"net/http"
)
func fetchUserName(id int) (string, error) {
resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%d", id))
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
return out.Name, nil
}
The decoder fills only the fields the struct names; the rest of the JSON is discarded. This is a strong reason to keep the struct anonymous and inline: the function uses just one field, so a named type would over-promise.
Example 4 — Slice of Anonymous Records¶
package main
import "fmt"
func main() {
rows := []struct {
Path string
Size int64
}{
{"/etc/hosts", 220},
{"/etc/passwd", 1340},
{"/etc/group", 760},
}
var total int64
for _, r := range rows {
total += r.Size
}
fmt.Println(total)
}
Example 5 — Returning a Small Bundle¶
package main
import "strings"
func split2(s, sep string) (struct{ A, B string }, bool) {
var out struct{ A, B string }
i := strings.Index(s, sep)
if i < 0 {
return out, false
}
out.A = s[:i]
out.B = s[i+len(sep):]
return out, true
}
A private helper. The shape exists only here. A named type is unnecessary.
10. Coding Patterns¶
Pattern 1 — Test Table¶
Pattern 2 — Inline Encode¶
Pattern 3 — Inline Decode (Pluck One Field)¶
Pattern 4 — Local Configuration¶
Pattern 5 — Embedded Subgroup¶
11. Clean Code Guidelines¶
- Default to named types. Anonymous is the exception, not the rule.
- Inline shapes only when local. Local meaning: one function, one file.
- Cap field count at four. Beyond that, the shape deserves a name.
- Always use named-field syntax. Positional initialization with anonymous structs reads badly.
- Do not export anonymous shapes. Move them to named types when they cross a package boundary.
- Do not log anonymous-struct values blindly. They have no
String()and you cannot redact fields.
// Good — local, small
got := struct{ A, B int }{1, 2}
// Bad — should be a named type
got := struct {
UserID, OrgID, RoleID int
Name, Email, Phone string
Active, Banned bool
}{ /* ... */ }
12. Product Use / Feature Example¶
A small admin endpoint that returns three counters:
package main
import (
"encoding/json"
"net/http"
)
type counters interface {
Users() int
Orgs() int
Sessions() int
}
func adminStats(c counters) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(struct {
Users int `json:"users"`
Orgs int `json:"orgs"`
Sessions int `json:"sessions"`
}{
Users: c.Users(),
Orgs: c.Orgs(),
Sessions: c.Sessions(),
})
}
}
The response shape exists only here. If three more endpoints need the same triple, refactor to a named type.
13. Error Handling¶
Anonymous structs cannot implement error. For error-shaped data inside a single helper, use a named field carrying an error:
Or, inline:
The shape carries an error but is not itself an error.
14. Security Considerations¶
- Auditability: a sensitive payload (passwords, tokens) deserves a named type so the schema can be reviewed centrally.
- No method overrides: you cannot add
MarshalJSONto redact fields. Use a named type when redaction matters. - Drift across files: two near-identical anonymous structs differ silently if a tag changes; security-sensitive fields are easy to miss.
- Logging: avoid logging anonymous-struct values containing secrets; you cannot define a custom
String()to mask fields.
15. Performance Tips¶
- Allocation behavior is identical to a named struct.
- Field padding follows the same rules — same
unsafe.Sizeof, same alignment. - Methods are missing, but a regular function with a struct parameter is just as fast.
- JSON encoding cost is the same —
encoding/jsonreflects on the type either way.
16. Metrics & Analytics¶
Inline metric events:
metrics.Emit(struct {
Name string `json:"name"`
Value int `json:"value"`
Tags []string `json:"tags"`
}{Name: "click", Value: 1, Tags: []string{"home"}})
For repeated event shapes, a named metrics.Event type wins because the catalog of events is part of the contract with downstream consumers.
17. Best Practices¶
- Anonymous for local one-offs.
- Named for cross-package, exported, or long-lived shapes.
- Cap field count.
- Always use named-field initialization.
- Avoid in public function signatures.
- Move to a named type as soon as a method is needed.
- Refactor when duplication appears.
18. Edge Cases & Pitfalls¶
Pitfall 1 — Tag Drift¶
// File a:
var a struct{ ID int `json:"id"` }
// File b:
var b struct{ ID int }
// a = b // not assignable: tag differs
Pitfall 2 — Cross-Package "Same" Shape¶
Each package's struct{ID int} is its own nominal type for the package's compilation unit's purposes. Even though structural identity says they're "the same" by shape, you still cannot easily exchange values across package boundaries because callers have to construct the literal themselves. Use a shared named type.
Pitfall 3 — Embedding Trap¶
type Outer struct {
Meta struct{ ID int }
ID int
}
// Outer.ID // top-level
// Outer.Meta.ID // nested — no collision because path differs
ID and also have Meta struct{ ID int }, you can hit ambiguity. Pitfall 4 — Public Function Signature¶
Fix: name the type.Pitfall 5 — Mutating a Slice of Anonymous Structs¶
xs := []struct{ N int }{{1}, {2}}
for _, x := range xs {
x.N = 99 // modifies the loop copy, not the slice element
}
fmt.Println(xs) // [{1} {2}]
i, write xs[i].N = 99. Pitfall 6 — Test Table That Outgrows Itself¶
cases := []struct {
name, in, want, lang, locale string
flag, debug bool
timeout time.Duration
setup func()
}{ /* 20 entries */ }
19. Common Mistakes¶
| Mistake | Fix |
|---|---|
| Anonymous struct in exported API | Use a named type |
| Trying to add methods | Use a named type |
| Duplicating the shape in two files | Promote to a named type |
| Forgetting tags must match | Be consistent or use a named type |
| Hugely wide test table | Split or define a named row type |
20. Common Misconceptions¶
Misconception 1: "Anonymous structs are slower." Truth: Identical layout and codegen.
Misconception 2: "Anonymous structs cannot have tags." Truth: They can. Tags are part of the type and visible to reflection.
Misconception 3: "Two struct{A int} in different packages are the same type." Truth: Identity is structural, but using the value across packages requires the caller to construct the literal — which is the rough part. It is mechanically possible (assignability holds), but it is bad design.
Misconception 4: "json.Marshal(struct{...}{...}) can't handle anonymous structs." Truth: It handles them like any other struct.
Misconception 5: "You should never use anonymous structs." Truth: They are idiomatic in tests and inline JSON. Avoid them in public APIs and where methods are needed.
21. Tricky Points¶
- Tags are part of the type — even invisible whitespace differences in tags will break identity (tags are byte-for-byte identical or different).
- Field ORDER is part of the type.
{A,B int}differs from{B,A int}. unsafe.Sizeofandreflect.Type.Sizegive identical results to the named twin.- A
reflect.Type.Name()call returns""for an anonymous struct. - The compiler still emits a runtime type descriptor; reflection works fully.
22. Test¶
package anon_test
import (
"encoding/json"
"testing"
)
func TestAnonAssign(t *testing.T) {
var a struct{ X int }
var b struct{ X int }
a = b // same type
_ = a
}
func TestAnonTagsDiffer(t *testing.T) {
a := struct {
X int `json:"x"`
}{X: 1}
out, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
if string(out) != `{"x":1}` {
t.Fatalf("got %s", out)
}
}
func TestTableDriven(t *testing.T) {
cases := []struct {
in, want int
}{
{1, 2}, {2, 3},
}
for _, c := range cases {
if got := c.in + 1; got != c.want {
t.Errorf("in=%d got=%d want=%d", c.in, got, c.want)
}
}
}
23. Tricky Questions¶
Q1: Are these the same type?
A: No. Tag strings are compared byte-for-byte; trailing whitespace is significant.Q2: Can interface{ Hello() } ever be satisfied by an anonymous struct? A: No. The struct cannot have methods, so it cannot satisfy any interface with methods.
Q3: Can an anonymous struct be a map key? A: Yes, as long as all its fields are comparable. The compiler synthesizes the equality.
Q4: Will this assign?
A: No. Field name differs.24. Cheat Sheet¶
// Single value
p := struct{ X, Y int }{1, 2}
// Slice with elided element type
xs := []struct{ A int }{{1}, {2}, {3}}
// Map of anonymous structs
m := map[string]struct{ A int }{"x": {1}}
// Test table
cases := []struct {
in, want int
}{
{1, 2},
}
// Inline JSON encode
_ = json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
}{"ok"})
// Inline JSON decode
var resp struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r).Decode(&resp)
// Embedded named field
type Outer struct {
Meta struct{ ID int }
}
25. Self-Assessment Checklist¶
- I default to named types and pick anonymous deliberately.
- I can list the conditions for two anonymous structs to share a type.
- I avoid anonymous structs in exported APIs.
- I refactor to a named type when methods or reuse appear.
- I cap anonymous structs at four fields in practice.
- I use named-field initialization.
- I know tags are part of the type.
26. Summary¶
Anonymous structs are for one-off, local shapes. They shine in table-driven tests and inline JSON shaping. They cannot have methods and they age poorly when reused or exported. Structural identity is strict: every field name, type, tag, and order must match. Performance and reflection behave just like a named struct. Promote to a named type the moment the shape is reused, exported, or needs methods.
27. What You Can Build¶
- Comprehensive test tables.
- Inline request/response handlers.
- Small ad-hoc payloads.
- Embedded "metadata" sub-structs.
- Quick configuration objects for a single helper.
- Local "tuple" returns from private functions.
28. Further Reading¶
- Go Spec — Struct types
- Go Spec — Type identity
- Effective Go — Composite literals
- Dave Cheney — Practical Go: Real-world advice
29. Related Topics¶
- 2.3.5 Structs
- 2.3.6 Empty Struct
- 2.4 Type Identity
- Method declarations (Chapter 3)
- Reflection basics