Methods on Defined Types — Find the Bug¶
Each exercise follows this format: 1. Buggy code 2. Hint 3. Identifying the bug and its cause 4. Fixed code
These bugs are specific to non-struct defined types — type aliases vs defined types, primitives wrapped as domain types, function types with methods, generic defined types, and embedded defined types whose method sets are easy to break in subtle ways.
Bug 1 — Methods on a type alias¶
package money
import "time"
// Looks like a "wrapper" — but uses '='.
type Timestamp = time.Time
func (t Timestamp) Unix() int64 {
return time.Time(t).Unix()
}
Hint: What kind of type does = create?
Bug: type Timestamp = time.Time is an alias, not a defined type. Aliases are the same type as their target. time.Time is declared in another package, and Go does not allow new methods to be defined on a non-local type. Compile error:
The author wanted a "richer Timestamp" but accidentally wrote =, turning the declaration into an alias and silently disabling the entire purpose of the file.
Fix: drop the = so it becomes a defined type.
type Timestamp time.Time // defined type — has its own method set
func (t Timestamp) Unix() int64 {
return time.Time(t).Unix()
}
Convert at boundaries: Timestamp(t) and time.Time(ts).
Bug 2 — Method silently lost after refactoring type X → type X = Y¶
// v1 — defined type, methods work
type UserID int64
func (u UserID) String() string { return fmt.Sprintf("u-%d", u) }
// ... later, somebody decides "UserID is just int64, let's simplify" ...
// v2 — refactor introduces an alias by mistake
type UserID = int64
// String method file deleted because "it's just int64 now"
Hint: Read the diff of the refactor — what changed besides removing the method?
Bug: Replacing type UserID int64 with type UserID = int64 looks like a one-character cleanup, but it is a destructive change:
- All methods previously defined on
UserIDhad to be removed (you cannot declare methods onint64). - Every
fmt.Stringer,json.Marshaler, etc. quietly disappears from the method set. - Code compiles fine because alias makes
UserIDinterchangeable withint64. The bug is invisible until logs or APIs start showing raw integers.
Fix: keep the defined type. If the goal is "less ceremony", document it instead of aliasing.
If you really need an alias, audit every method that was attached and decide, explicitly, what to do with each one.
Bug 3 — Wrong conversion at the boundary¶
type MyTime time.Time
func (m MyTime) IsWeekend() bool {
d := m.Weekday() // ?
return d == time.Saturday || d == time.Sunday
}
func Schedule(t time.Time) {
m := MyTime(t)
if m.IsWeekend() { /* ... */ }
}
Hint: Does MyTime inherit methods from time.Time?
Bug: A defined type built on another type does not inherit the source type's methods. MyTime does not have Weekday(). Compile error:
People often confuse this with embedding — but type MyTime time.Time is a plain type definition, not a struct with an embedded field.
Fix: convert back to time.Time to call its methods.
func (m MyTime) IsWeekend() bool {
d := time.Time(m).Weekday()
return d == time.Saturday || d == time.Sunday
}
Or embed the type if you want method promotion:
Bug 4 — Method lost on slice copy through []T vs []Defined¶
type Email string
func (e Email) Domain() string {
i := strings.IndexByte(string(e), '@')
if i < 0 { return "" }
return string(e)[i+1:]
}
func collectDomains(addrs []string) []string {
out := make([]string, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.Domain()) // ?
}
return out
}
Hint: What is the element type of addrs?
Bug: The function takes []string, not []Email. Even though Email's underlying type is string, the method Domain is in Email's method set, not string's. Compile error:
The classic mistake is to "save a conversion" by accepting the underlying type. The cost is the entire reason Email exists — the methods.
Fix: accept the defined type, or convert per element.
func collectDomains(addrs []Email) []string {
out := make([]string, 0, len(addrs))
for _, a := range addrs { out = append(out, a.Domain()) }
return out
}
If callers genuinely have []string, convert at the boundary:
Bug 5 — Pointer-receiver method on map element of a defined int¶
type Counter int
func (c *Counter) Inc() { *c++ }
func main() {
counters := map[string]Counter{"hits": 0}
counters["hits"].Inc()
fmt.Println(counters["hits"])
}
Hint: Can you take the address of a map element?
Bug: Inc has a pointer receiver, so calling counters["hits"].Inc() needs &counters["hits"]. Map elements are not addressable. Compile error:
This bites harder for type Counter int than for structs because there is no visual "this is heavy state" — people assume an int should be cheap to mutate in place.
Fix: read, mutate, write back.
Or store pointers:
Or use an immutable style with a value receiver that returns a new value:
Bug 6 — Domain primitive accidentally compared to a plain string¶
type UserID string
func currentUser() UserID { return "alice" }
func isAdmin(id UserID) bool {
admins := []string{"alice", "bob"}
for _, a := range admins {
if id == a { // ?
return true
}
}
return false
}
Hint: Is id == a a string comparison or a UserID comparison?
Bug: id is UserID, a is string. They share the same underlying type but they are different types. Comparison between defined types and their underlying types is not allowed without conversion. Compile error:
The seductive variant is when the literal sneaks through: id == "alice" works (the untyped string constant is convertible to UserID), but as soon as you put the literals into a []string, the comparison breaks. The worse-case scenario is when somebody "fixes" it by writing if string(id) == a, which throws away the type safety the wrapper was created to provide.
Fix: keep the type at both ends.
Or make the conversion explicit and intentional in one direction only:
Bug 7 — HandlerFunc-style adapter that calls itself¶
type HandlerFunc func(w http.ResponseWriter, r *http.Request)
// Make HandlerFunc satisfy http.Handler.
func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.ServeHTTP(w, r) // ?
}
Hint: What does h.ServeHTTP resolve to?
Bug: The adapter is supposed to dispatch by calling the underlying function value: h(w, r). Instead, the author wrote h.ServeHTTP(w, r), which is the same method that is currently executing. Result: infinite recursion until the goroutine's stack grows past runtime.GOMAXSTACK and the program crashes with runtime: goroutine stack exceeds 1000000000-byte limit.
The reason this happens so often: people copy the pattern from net/http.HandlerFunc and forget that the whole point of the adapter is to call h as a function, not as a method.
Fix: call the function value.
func (h HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h(w, r) // call the function, not the method
}
Bug 8 — Errno type compared to literal 0¶
type Errno int
func (e Errno) Error() string { return fmt.Sprintf("errno %d", e) }
func syscallX() error {
var e Errno = 0
return e // ?
}
func main() {
if err := syscallX(); err != nil {
fmt.Println("failed:", err)
} else {
fmt.Println("ok")
}
}
Hint: What does return e mean when e == 0 and the return type is error?
Bug: Two interacting problems, both very common with type Errno int:
return ereturns anerrorinterface whose dynamic type isErrnoand dynamic value is0. The interface value is not nil —err != nilis true even whene == 0. The caller mistakenly thinks the call failed.- If the author tries to "fix" it inside the syscall with
if e == 0 { return nil }, that works — but if they then writeif err == 0at the call site (treating the returnederroras an int), it does not even compile, becauseerroris not comparable to0.
This is the Go FAQ's classic "typed nil" trap, with Errno as the canonical victim because the zero value of an int-based error type is exactly the "success" sentinel.
Fix: in the producer, convert sentinel zero to a literal nil before returning.
func syscallX() error {
var e Errno = 0
if e == 0 { // compare as Errno, not as the error interface
return nil
}
return e
}
Equivalently and more idiomatically:
func syscallX() error {
e := doSyscall() // returns Errno
if e == Errno(0) {
return nil
}
return e
}
At call sites, never compare err == 0; always compare err == nil or use errors.Is.
Bug 9 — Generic defined type instantiated with the wrong parameter¶
type Set[T comparable] map[T]struct{}
func (s Set[T]) Add(v T) { s[v] = struct{}{} }
func (s Set[T]) Has(v T) bool { _, ok := s[v]; return ok }
type UserID string
func main() {
ids := Set[string]{} // ?
var u UserID = "alice"
ids.Add(u)
}
Hint: What is the parameter T and what does Add accept?
Bug: The set is instantiated with string, so Add accepts string, not UserID. ids.Add(u) fails to compile:
The seductive fix is to write ids.Add(string(u)). That compiles, but it silently downgrades every value going into the set to string, defeating the point of using UserID. Worse, ids.Has(otherID) then also needs a conversion, and a stray ids.Has("alice") (plain string literal) compiles without complaint — exactly the kind of mistake UserID was meant to catch.
Fix: instantiate with the domain type.
ids := Set[UserID]{}
ids.Add(u) // type-checked
ids.Has("alice") // still ok — untyped constant converts to UserID
ids.Has(plainString) // compile error if plainString is string — good!
Bug 10 — Embedded defined type whose methods are not promoted¶
type Email string
func (e Email) Domain() string {
i := strings.IndexByte(string(e), '@')
if i < 0 { return "" }
return string(e)[i+1:]
}
type Contact struct {
primary Email // not embedded — named field
backup Email
}
func main() {
c := Contact{primary: "alice@example.com"}
fmt.Println(c.Domain()) // ?
}
Hint: Is primary embedded or just a field?
Bug: primary Email is a named field, not an embedded field. Method promotion only happens for anonymous (embedded) fields. The outer struct Contact does not gain Domain(). Compile error:
The asymmetric variant is even nastier: a refactor renames an embedded Email to a named Primary Email, and every call site that relied on c.Domain() breaks at once — but only at compile time for direct calls. If Contact was being passed through an interface{ Domain() string }, that interface assertion now fails at runtime with *Contact does not implement DomainProvider, often deep inside a generic helper.
Fix: embed the type if you want promotion.
type Contact struct {
Email // embedded — Domain() is promoted
Backup Email // named — explicit access via c.Backup.Domain()
}
c := Contact{Email: "alice@example.com"}
fmt.Println(c.Domain()) // works — promoted from Email
fmt.Println(c.Backup.Domain()) // explicit
If both fields need promotion of the same method, you cannot embed both — you must call the methods explicitly.
Bug 11 — Embedded defined type with pointer receivers, outer used by value¶
type Counter int
func (c *Counter) Inc() { *c++ }
func (c Counter) Value() int { return int(c) }
type Stats struct {
Counter // embedded
name string
}
func track(s Stats) {
s.Inc() // ?
}
func main() {
s := Stats{name: "hits"}
track(s)
fmt.Println(s.Value())
}
Hint: Two layers of receiver/value confusion.
Bug: Two cooperating problems:
tracktakesStatsby value. The embeddedCounteris copied.s.Inc()does compile (Go takes the address of the locals.Counter), but the increment happens on the copy — the caller'ssis unchanged.- Because
Inchas a pointer receiver and the outerStatsis held by value, an interface likeinterface{ Inc() }cannot be satisfied by a plainStatsvalue: the method set ofStatsonly contains the methods whose receivers are value-typed in the outer-or-embedded sense. You need*Stats.
The defined-type angle is what makes this subtle: the same trap on a struct embedded type is more visible because struct types are usually big and people instinctively pass them by pointer; an int-shaped Counter looks "cheap" and tempts pass-by-value.
Fix: pass *Stats and operate on it through the pointer.
func track(s *Stats) { s.Inc() }
func main() {
s := &Stats{name: "hits"}
track(s)
fmt.Println(s.Value()) // 1
}
Or, if you need a value-typed API, give Counter a value receiver that returns a new value, and re-assign:
func (c Counter) Inc() Counter { return c + 1 }
func track(s Stats) Stats { s.Counter = s.Counter.Inc(); return s }
s = track(s)
Cheat Sheet¶
DEFINED-TYPE METHOD BUGS
─────────────────────────────
1. type X = Y → cannot add methods (alias, not defined type)
2. Refactor to alias → silently drops every method on X
3. type X Y → does NOT inherit Y's methods; convert at boundary
4. []string vs []X → method set lives on X, not on its underlying type
5. type X int + *X.M → map["k"].M() is non-addressable: read-modify-write
6. UserID == string → mismatched types; keep domain type on both sides
7. HandlerFunc.M → call h(...), not h.M(...) — recursion otherwise
8. Errno(0) vs nil → typed-nil trap; return literal nil from producer
9. Generic[T] → instantiate with the domain type, not its underlying
10. Named field → no method promotion; embed (anonymous) for promotion
11. *receiver + value outer → method set excludes *-receiver methods
QUICK CHECKS
─────────────────────────────
go vet ./... # typed-nil, copylocks, etc.
go vet -vettool=$(which fieldalignment) ./...
staticcheck ./... # ST1016 receiver names, SA4023 typed nil
RULE OF THUMB
─────────────────────────────
- Use 'type X Y' when X has invariants, methods, or domain meaning.
- Use 'type X = Y' only for migrations / package renames.
- Convert at the boundary; never let underlying types leak into APIs.
- Pointer receiver? Decide once per type, apply consistently.