Types, Structs & Methods
This chapter covers how Go’s named types, struct literals, field tags, and method receivers actually work — and the concrete decision rules for choosing between value and pointer receivers, when to write a NewXxx constructor, and how a type’s method set decides whether it satisfies an interface. We’ll ground each idea in real code from multigres (“Vitess for Postgres”), a single-module Go monorepo.
A method can only be declared in the same package as the type it’s attached to, so package boundaries matter here. If you haven’t read Packages, Modules & Imports, the section on the local-import prefix github.com/multigres is worth a skim first.
Type definitions vs type aliases
Section titled “Type definitions vs type aliases”Go has two superficially similar type forms that mean completely different things. This is the single most common confusion for newcomers, and the codebase has clean real examples of each.
A type definition creates a brand-new named type with a distinct identity. The new type shares the same underlying type (memory layout, operators) but is not interchangeable with it — you need an explicit conversion to cross between them, and you can attach your own methods to the new type.
type Celsius float64 // definition: a NEW type whose underlying type is float64
func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", float64(c))}
var t Celsius = 36.6 // ok: an untyped constant converts implicitlyvar f float64 = 36.6// t = f // COMPILE ERROR: cannot use f (float64) as Celsiust = Celsius(f) // ok: explicit conversion requiredA type alias uses the = form. It does not create a new type — it’s just a second spelling for an existing type. Values are assignable in both directions with no conversion, and you cannot attach methods through the alias (the underlying type is owned by someone else).
type Temp = float64 // alias: Temp and float64 are the SAME typevar x Temp = 1.5var y float64 = x // ok, no conversion: they are identical// func (t Temp) Foo() {} // COMPILE ERROR: cannot define methods on float64 hereA definition over a builtin
Section titled “A definition over a builtin”The AST package defines PostgreSQL’s object identifier as a distinct type over uint32:
type Oid uint32This is a definition, not an alias. The payoff is type safety: a bare uint32 could be anything, but an Oid is unambiguously a PostgreSQL type OID. Named constants like ast.TEXTOID make the values self-documenting, and the guidance is to never hardcode uint32 = 25 for TEXT — use the named constant. Because Oid is its own type, code that mixes a raw int with an OID won’t compile without a visible Oid(x) conversion — the conversion is the documentation.
An alias to re-export a third-party type
Section titled “An alias to re-export a third-party type”A utility package re-exports a type from the pglogrepl dependency under a local, documented name:
// LSN is a PostgreSQL Log Sequence Number.type LSN = pglogrepl.LSNThe = makes pgutil.LSN the exact same type as pglogrepl.LSN. A value produced by the dependency can be stored in a variable typed pgutil.LSN and vice versa with no conversion — the local ParseLSN just forwards to pglogrepl.ParseLSN and returns the result directly. The alias gives callers a stable local name without wrapping or re-implementing anything.
An alias as a migration shim
Section titled “An alias as a migration shim”Aliases also smooth in-place refactors. The lexer defines its token type as an alias:
// TokenType represents the type of a lexical token.// This is now just an alias for int to maintain compatibility during transition.// New code should use the generated parser constants directly.type TokenType = intBecause TokenType is int, every existing site that passed a plain int keeps compiling while the code migrates to the generated constants. A definition (type TokenType int) would have broken all those call sites at once by demanding conversions. This “alias to avoid a big-bang rename” use is exactly what the language designers added aliases for.
Structs and struct literals
Section titled “Structs and struct literals”A struct groups named fields. The codebase constructs structs with the keyed literal form everywhere; the positional form is avoided.
type Point struct { X, Y int}
p := Point{X: 1, Y: 2} // keyed: explicit, order-independent, future-proofq := Point{3, 4} // positional: fragile — breaks/silently shifts if fields are addedHere’s a real constructor that sets every field by name:
func NewVar(varno int, varattno AttrNumber, vartype Oid) *Var { return &Var{ BaseExpr: BaseExpr{BaseNode: BaseNode{Tag: T_Var}}, Varno: varno, Varattno: varattno, Vartype: vartype, }}Notice that Var has more fields than this literal sets (Vartypmod, Varcollid, Varlevelsup, …). The unset fields are not errors — they take their type’s zero value (0 for the int-like fields). Keyed literals let you set only what matters and lean on zero values for the rest.
Field tags
Section titled “Field tags”A struct field can carry a backtick-quoted tag: arbitrary string metadata that library code reads at runtime via reflection. The compiler doesn’t interpret tags; encoders like encoding/json, gopkg.in/yaml.v3, and the protobuf runtime do.
type Server struct { Host string `yaml:"host"` Port int `yaml:"port,omitempty"`}The convention is key:"value,option1,option2". omitempty tells the marshaler to skip the field when it equals its zero value.
YAML config tags, the small case
Section titled “YAML config tags, the small case”Start with the smallest real example — the cluster config:
type MultigresConfig struct { Provisioner string `yaml:"provisioner"` ProvisionerConfig map[string]any `yaml:"provisioner-config,omitempty"`}And the round trip that reads those tags via reflection:
var config MultigresConfigif err := yaml.Unmarshal(data, &config); err != nil { /* ... */ }if config.Provisioner == "" { return nil, "", fmt.Errorf("provisioner not specified in config file %s", configFile)}yaml.Unmarshal uses the yaml:"provisioner" tag to map the YAML key provisioner onto the Go field Provisioner (the exported Go field is capitalized; the wire name is lowercase). The map[string]any type for ProvisionerConfig holds arbitrary nested config that’s decoded later by whichever provisioner is selected.
YAML config tags, bigger — with pointers and renames
Section titled “YAML config tags, bigger — with pointers and renames”The local provisioner shows the full vocabulary — renamed wire names, omitempty, pointer fields for optional sub-config, and nested maps:
type BackupConfig struct { Type string `yaml:"type"` // "local", "s3", "azure", etc. Local *LocalBackup `yaml:"local,omitempty"` S3 *S3Backup `yaml:"s3,omitempty"`}
type S3Backup struct { Bucket string `yaml:"bucket"` Region string `yaml:"region"` Endpoint string `yaml:"endpoint,omitempty"` KeyPrefix string `yaml:"key-prefix,omitempty"` UseEnvCredentials bool `yaml:"use-env-credentials,omitempty"`}Two things to internalize. First, the tag renames the Go identifier to the wire name: the field KeyPrefix serializes as key-prefix. The Go name follows Go conventions (exported, CamelCase) while the YAML stays kebab-case. Second, the optional sub-configs are pointers (*LocalBackup, *S3Backup): a pointer field can be nil, which distinguishes “this block was absent” from “this block was present but empty”. A non-pointer struct field can’t represent absence — it would just be the zero-value struct.
Generated protobuf tags — dual tags, opaque fields
Section titled “Generated protobuf tags — dual tags, opaque fields”Generated code carries two tags per field — one for protobuf, one for JSON — plus unexported bookkeeping:
type BuildInfo struct { state protoimpl.MessageState `protogen:"open.v1"` Revision string `protobuf:"bytes,1,opt,name=revision,proto3" json:"revision,omitempty"` Modified bool `protobuf:"varint,2,opt,name=modified,proto3" json:"modified,omitempty"` CommitTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=commit_time,json=commitTime,proto3" json:"commit_time,omitempty"` GoVersion string `protobuf:"bytes,4,opt,name=go_version,json=goVersion,proto3" json:"go_version,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache}A single field tag can hold multiple key:"..." groups separated by spaces — here protobuf: carries the wire type, field number, and proto name; json: carries the JSON name. The protobuf:"...,1,..." field numbers are the wire identity in protobuf, not the Go names. The message field CommitTime is a pointer (*timestamppb.Timestamp) — protobuf message-typed fields are pointers so absence is representable as nil.
Methods and receivers
Section titled “Methods and receivers”A method is a function with an extra parameter — the receiver — written before the name. The receiver type must be a named type defined in the same package. The receiver doesn’t have to be a struct: it can be any named type, including one whose underlying type is a slice or an integer.
// Method on a named numeric type.func (c Celsius) String() string { // receiver c of type Celsius return fmt.Sprintf("%.1f°C", float64(c))}Value-receiver methods on a named slice type
Section titled “Value-receiver methods on a named slice type”The sqltypes package defines a column value as a named slice type and hangs read-only methods on it:
// Value represents a nullable column value.// nil means NULL, []byte{} means empty string.type Value []byte
// IsNull returns true if the value is NULL.func (v Value) IsNull() bool { return v == nil}Value is type Value []byte — a definition, so it owns its method set. IsNull, IsTrue, and SQLLiteral are all value receivers (func (v Value) ...) because they only read the receiver. A value receiver gets its own copy of the receiver, but for a slice that copy is cheap — it copies the three-word slice header (pointer, length, capacity), not the backing bytes. There’s no reason to use a pointer here, and value receivers keep the call site clean: you call v.IsNull() on any Value, addressable or not.
Value vs pointer receivers: the decision rule
Section titled “Value vs pointer receivers: the decision rule”This is the practical heart of the chapter. The rule the codebase follows:
Use a pointer receiver (*T) when any of these hold:
- the method mutates the receiver (otherwise it mutates a copy that’s thrown away);
- the struct is large and copying it per call is wasteful;
- (decisive in practice) the struct contains a
sync.Mutexor any other field that must not be copied; - for consistency — if some method on the type needs a pointer receiver, the convention is to make all of them pointer receivers so the type has one uniform method set.
Use a value receiver (T) when the type is small, the method only reads, and the value is naturally immutable — like Value []byte above.
The decisive pointer-receiver case: Consolidator
Section titled “The decisive pointer-receiver case: Consolidator”Consolidator deduplicates prepared statements across connections. It embeds a mutex and three maps:
type Consolidator struct { mu sync.Mutex // protects the fields below
stmts map[string]*PreparedStatementInfo incoming map[uint32]map[string]*PreparedStatementInfo usageCount map[*PreparedStatementInfo]int lastUsedID int}Every method takes a pointer receiver and locks the mutex:
func (psc *Consolidator) AddPreparedStatement(connId uint32, name, queryStr string, paramTypes []uint32) (*PreparedStatementInfo, error) { psc.mu.Lock() defer psc.mu.Unlock() // ...}Two of the rule’s clauses fire at once. The method mutates state (it inserts into the maps and bumps lastUsedID), so a value receiver would mutate a discarded copy. More fundamentally, sync.Mutex must not be copied — a value receiver would copy the lock on every call, defeating mutual exclusion. go vet’s copylocks check flags exactly this. So the pointer receiver is mandatory, not stylistic.
The value-receiver case: Value
Section titled “The value-receiver case: Value”Contrast Value []byte from above: small (a slice header), read-only methods, no lock, naturally treated as an immutable value. A value receiver is correct and idiomatic. The *Result type in the same file goes the other way — it’s a larger struct and gets a pointer-receiver method, covered below.
The pair Consolidator (pointer, mutable, has a mutex, needs a constructor) versus Value (value, immutable, no constructor) is the cleanest way to remember the rule. For the underlying cost model — why copies matter and how the compiler decides what escapes to the heap — see Pointers, Values & Memory.
Method sets and interface satisfaction
Section titled “Method sets and interface satisfaction”A type’s method set is the set of methods you can call through a value of that type — and it’s what decides whether the type satisfies an interface. The rule that bites newcomers:
- The method set of a value
Tcontains only methods declared with a value receiver(T). - The method set of a pointer
*Tcontains methods declared with either receiver — both(T)and(*T).
Consequence: if a type has any pointer-receiver method that an interface requires, only the pointer *T satisfies that interface — the value T does not.
type Stringer interface{ String() string }
type Counter struct{ n int }func (c *Counter) String() string { return strconv.Itoa(c.n) } // POINTER receiver
var _ Stringer = &Counter{} // ok: *Counter has String()// var _ Stringer = Counter{} // COMPILE ERROR: Counter (value) lacks String() in its method set*fundamental implements error only via pointer
Section titled “*fundamental implements error only via pointer”The base error type embeds a stack and declares Error on a pointer receiver:
// fundamental is an error that has a message and a stack, but no caller.type fundamental struct { msg string code mtrpcpb.Code *stack}
func (f *fundamental) Error() string { return f.msg }Error(), Format(), and the code accessor are all pointer receivers, so only *fundamental satisfies the error interface (and fmt.Formatter). That’s why the constructors hand back a &fundamental{...} (a pointer) rather than a value:
func New(code mtrpcpb.Code, message string) error { return &fundamental{ msg: message, code: code, stack: callers(), }}The same pattern governs PgDiagnostic. Its methods use pointer receivers, so only *PgDiagnostic implements error, and callers extract it with a pointer target. This is real production code, not just a test:
var diag *mterrors.PgDiagnosticif errors.As(err, &diag) { switch diag.Code { /* ... */ }}errors.As walks the error chain looking for something assignable to diag — the declared type *PgDiagnostic is exactly the type that satisfies error, so the target passed is &diag (a **PgDiagnostic). The pointer is needed anyway to carry shared, possibly-large diagnostic data.
Zero values and useful-zero-value design
Section titled “Zero values and useful-zero-value design”Every type in Go has a zero value that you get when you declare a variable without initializing it: 0 for numbers, "" for strings, false for bools, nil for pointers/slices/maps/channels/interfaces/functions, and recursively the zero of each field for structs. Good Go design makes the zero value useful so callers can skip a constructor.
sync.Mutex is usable at its zero value
Section titled “sync.Mutex is usable at its zero value”A freshly declared var mu sync.Mutex is ready to Lock() — no initialization needed. That’s why Consolidator.mu works without any setup in its constructor; only the maps need allocating.
Value(nil) deliberately means SQL NULL
Section titled “Value(nil) deliberately means SQL NULL”func (v Value) IsNull() bool { return v == nil}The zero value of Value (which is nil, since the underlying type is []byte) is given a domain meaning: SQL NULL. The comment on the type spells out the contract — nil means NULL, []byte{} means the empty string. These are two distinct states, and IsNull checks identity with nil, so an empty-but-non-nil slice is not null.
A nil pointer receiver can be a valid no-op
Section titled “A nil pointer receiver can be a valid no-op”Result is the larger sibling of Value, and its ToProto method guards against a nil receiver:
func (r *Result) ToProto() *query.QueryResult { if r == nil { return nil } // ...}Calling a pointer-receiver method on a nil pointer is legal in Go — r is simply nil inside the method. It only panics if the body dereferences a field of r. By checking if r == nil first and returning nil, the method treats “no result” as a valid input and produces “no proto”, letting callers write result.ToProto() without a nil check at every call site.
The NewXxx constructor convention
Section titled “The NewXxx constructor convention”Go has no constructors built into the language. The idiom is a package-level function named New or NewType that returns *T (or sometimes an interface). The discriminating question: write a constructor only when the zero value is not enough.
When a constructor is mandatory: NewConsolidator
Section titled “When a constructor is mandatory: NewConsolidator”func NewConsolidator() *Consolidator { return &Consolidator{ stmts: make(map[string]*PreparedStatementInfo), incoming: make(map[uint32]map[string]*PreparedStatementInfo), usageCount: make(map[*PreparedStatementInfo]int), lastUsedID: 0, }}The zero value of a map is nil, and writing to a nil map panics (reading and ranging are fine; writing is not). Consolidator has three maps that get written, so a bare Consolidator{} would panic on the first AddPreparedStatement. The constructor make()s the maps so the value is usable. This is the litmus test: if the zero value would panic or behave wrongly, a constructor is mandatory.
When a constructor is still used though no maps/mutex: the AST NewXxx family
Section titled “When a constructor is still used though no maps/mutex: the AST NewXxx family”Look again at NewVar (and its siblings NewConst, NewParam):
func NewVar(varno int, varattno AttrNumber, vartype Oid) *Var { return &Var{ BaseExpr: BaseExpr{BaseNode: BaseNode{Tag: T_Var}}, Varno: varno, Varattno: varattno, Vartype: vartype, }}Var has no maps and no mutex, so why a constructor? Because the embedded Tag field must be set to the correct discriminant (T_Var) and the zero value (T_Invalid) would be wrong. The constructor encapsulates “set the tag correctly” so no caller can forget it. A constructor is justified whenever there’s a non-zero invariant to establish, not only when there’s memory to allocate. (And by convention they return *Var, not Var, because AST nodes are passed around by pointer and have pointer-receiver methods.)
Embedding (teaser)
Section titled “Embedding (teaser)”Go has no inheritance. Instead it has embedding: declare a field with no name — just a type — and that type’s exported fields and methods are promoted onto the outer struct. It’s composition that looks like inheritance at the call site.
The AST nodes form a two-level embedding chain. The base provides fields and methods:
type BaseNode struct { Tag NodeTag // Node type tag Loc int // Source location in bytes}
func (n *BaseNode) NodeTag() NodeTag { return n.Tag }func (n *BaseNode) Location() int { return n.Loc }The middle layer embeds the base and adds to it:
// BaseExpr provides common expression functionality.type BaseExpr struct { BaseNode}
func (e *BaseExpr) IsExpr() bool { return true }And a concrete node embeds the middle layer (recall Var from earlier: type Var struct { BaseExpr; Varno int; ... }). Through two levels of promotion, a *Var can call Location() — even though Var declares no Location method — because the method is promoted up from *BaseNode through BaseExpr. That’s exactly why Var.String() can write v.Location().
The error types use the same mechanism with pointer embedding: fundamental embeds *stack, so the stack-formatting methods are promoted onto *fundamental.
Checkpoints
Section titled “Checkpoints”-
You see
type Status = intandtype Status int. Which one lets you definefunc (s Status) String() stringin this package, and which one requiresStatus(x)to assign from a plainint?Answer
Only the definitiontype Status intlets you attach methods (you own the type) and requires an explicit conversionStatus(x)fromint. The aliastype Status = intis the same type asint— you cannot add methods to it here, andintvalues assign directly with no conversion. Seetype Oid uint32(definition) vstype TokenType = int(alias). -
Consolidatorhas all pointer receivers and a mandatoryNewConsolidator, butValuehas value receivers and no constructor. Give the one structural fact aboutConsolidatorthat forces each of those choices.Answer
Consolidatorcontains async.Mutex(must never be copied, so pointer receivers) and three maps that get written (a nil map write panics, somake()them inNewConsolidator— the zero value is unusable).Value []bytehas neither: it is small, read-only, and its zero value (nil) is meaningful, so value receivers and no constructor are correct. -
Why does
mterrors.Newreturn&fundamental{...}rather thanfundamental{...}?Answer
Error()(andFormat) are declared on a pointer receiver(*fundamental). Only*fundamentalhas those methods in its method set, so only the pointer satisfies theerrorinterface. Returning a valuefundamental{...}aserrorwould fail to compile. -
A method does
func (r *Result) ToProto() *query.QueryResult { if r == nil { return nil }; ... }. Why does calling this on a nil*Resultnot panic, but removing the guard would?Answer
Calling a pointer-receiver method on a nil pointer is legal —ris justnilinside. The panic happens only when the body dereferences a field ofr. The guard returns before touchingr.Rows/r.Fields; without it, the first field access would panic with a nil-pointer dereference.
Exercises
Section titled “Exercises”-
In
go/common/parser/ast/expressions.go, find everyNewXxxconstructor (NewVar,NewConst,NewParam, and any others). Confirm they all return*Tand all initializeBaseExpr{BaseNode{Tag: ...}}. Explain why a constructor is justified here even though these structs have no maps or mutexes. (Hint: the embeddedTagmust not be left at its zero value.) -
Grep the alias form
type \w+ =versus the definition formstype \w+ uint32/type \w+ intundergo/. List three of each (e.g.LSN,TokenTypeas aliases vsOid,AttrNumberas definitions). For each, state whether you could attach a method to it in its own package and whether assigning from the underlying type needs an explicit conversion. -
Open
go/common/preparedstatement/consolidator.go. In two sentences, explain whyAddPreparedStatementuses a pointer receiver and whyNewConsolidatormust exist. Then grepgo/common/for another struct that embedssync.Mutexand verify it also has aNewXxxconstructor and pointer-receiver methods. -
Trace the embedding chain
Var → BaseExpr → BaseNodeingo/common/parser/ast/. Find the line inexpressions.gowhere.Location()is called on a*Vareven thoughVardeclares noLocationmethod, and explain how method promotion makes it compile. Bonus: why areBaseNode’s receivers pointer receivers?