skip to content

Language Trade-Offs

The arguments Go's designers actually made: error values instead of exceptions, embedding instead of subclassing, and features left out on purpose. Interviews often open by asking you to defend one.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

17

Go has no `extends` keyword - how do you reuse another type's behaviour inside a struct?

level: juniorimportance: must knowfreq 78%

answer

  1. reuse is a field, not a keyword
  2. the field name is the whole difference
  3. no field name means methods are promoted
  4. a named field forwards only what you choose

basics

~20 s

Go gives three choices: embed the type anonymously so its methods are promoted onto yours, hold it in a named field and forward only the calls you want, or depend on an interface it already satisfies.

solid answer

~50 s

There is no inheritance in Go, so reuse is written as a field. If I write the type name with no field name - `type Server struct { Logger }` - that is embedding: every method of `Logger` becomes callable as `s.Log(...)` and joins `Server`'s own method set, so `Server` also satisfies any interface `Logger` satisfied. If I give the field a name - `logger Logger` - nothing is promoted; callers reach it as `s.logger.Log(...)` and I hand-write forwarding methods for whatever I want on my own surface. The third option is not a field at all: declare a parameter or field of an interface type, and any value with the right methods fits, because satisfaction is implicit. Embedding is the least typing and the widest commitment; a named field is more typing and keeps the surface mine.

code

go · 14 lines
go
type Logger struct{ prefix string }

func (l Logger) Log(msg string) {}

type Cache struct{}

func (c *Cache) Get(k string) (string, bool) { return "", false }

type Server struct {
	Logger // embedded: Log is promoted onto Server
	cache *Cache // named: reached only as s.cache.Get(...)
}

// s.Log("started") compiles; s.Get("k") does not.

go deeper

for a junior

Be ready to name the three forms - embedded field, named field, interface - and to say that the missing field name is what turns an ordinary field into an embedding.

for a middle

Expect to explain that promotion puts the embedded type's methods into the outer type's method set, so the outer type silently satisfies interfaces the inner one satisfied.

for a senior

Show that you treat the choice as an API decision rather than a style one: what you embed, you publish. Expect a follow-up about what you would do differently in a package other teams import.

for a principal

Own the rule for the codebase: where embedding is allowed, where hand-written forwarding is mandatory, and how you stop a reviewer having to relitigate it on every pull request.

## Reuse in a language with no base classes Go has no classes, no `extends`, no `super` and no base-class constructors. Behaviour is attached to types by declaring methods on them, and reuse of *someone else's* behaviour is always spelled out as data: you put the other type inside your struct, or you accept it through an interface. There are exactly three concrete forms, and picking between them is one of the first real design decisions a Go author makes. ### 1. An embedded field Write the type with no field name: ```go type Server struct { Logger } ``` `Logger` is both the type and the field name here, so `s.Logger` is a valid selector. What separates this from an ordinary field is **promotion**: every method in `Logger`'s method set becomes callable on `Server` as `s.Log(...)`, and joins `Server`'s *own* method set. That last part is the consequential one. Interface satisfaction in Go is structural and implicit - a type satisfies an interface simply by having the right methods, with no declaration anywhere - so if `Logger` satisfied some interface, `Server` now satisfies it too. Exported fields of an embedded struct are promoted the same way, reachable as `s.Field`. Promotion is name resolution, not dynamic dispatch: nothing about it makes `Server` a kind of `Logger`, and a `*Server` cannot be passed where a `*Logger` is wanted. ### 2. A named field ```go type Server struct { logger Logger } ``` Now nothing is promoted. Code inside the package reaches the dependency as `s.logger.Log(...)`, and if the outside world should be able to log through a `Server`, you write that method yourself: ```go func (s *Server) Log(msg string) { s.logger.Log(msg) } ``` That is more typing, and it is the whole point: every method on your type is one you chose, named and documented. You can also swap the field's type, wrap it, guard it, or add a parameter, without anything leaking that you did not intend. ### 3. An interface The third form removes the concrete dependency altogether. Declare the field or parameter as an interface type and accept anything with the right methods. Because satisfaction is implicit, the implementing type does not need to know your interface exists - it can live in a package that was written years earlier. This is what makes small interfaces so cheap in Go, and it is the mechanism behind substituting a fake in a test or a different backend in production. ### Choosing between them A useful rule of thumb: - **Embed** when the embedded type's *entire* API is deliberately part of what your type offers - for example when you are decorating something and want every method you did not override to pass through unchanged. Embedding is the only one of the three that gives you that for free. - **Named field** by default, especially on an exported struct. You pay a few forwarding methods and you keep control of your surface. - **Interface** when you want the dependency to be replaceable, or when your type genuinely does not care which implementation it gets. ### Consequences people are surprised by Embedding reaches further than the method set: - **Encoding.** `encoding/json` treats an embedded struct's exported fields as if they were declared on the outer struct, so they appear at the top level of the JSON object rather than nested. Embedding therefore changes your wire format, not only your call sites. Giving the embedded field a JSON tag turns it back into a normal nested object. - **Interface satisfaction.** Gaining a method silently can change behaviour at a distance. A type that embeds something with a `String() string` method becomes a `fmt.Stringer`, and `fmt.Println` will print it differently from the day before. - **Visibility.** Whether a promoted method is exported depends on the *method's* name, not the field's. An unexported embedded type still promotes its exported methods onto an exported outer type. - **Name collisions.** The embedded type's names live in your struct's namespace, so a second embedded field with a matching name creates an ambiguity that the compiler reports where the selector is used. The short version: a named field costs you keystrokes, an embedded field costs you control. Both are composition; only one of them is also publication.

  • Does embedding make the outer type satisfy interfaces the embedded type satisfied?
    Yes. Promoted methods are in the outer type's method set, so if `Logger` satisfied an interface with a `Log` method, `Server` satisfies it too, with no declaration anywhere. That is often exactly why you embed - and also why an accidental embed silently widens what your type can be passed to.
  • What does embedding do to the struct's JSON encoding?
    `encoding/json` promotes an embedded struct's exported fields into the outer JSON object, so they appear at the top level instead of nested under a field name. Adding a JSON tag to the embedded field makes it a normal nested object again. So embedding changes your serialised shape as well as your method set.
  • If you only want two of the embedded type's ten methods, what should you write?
    A named field plus two forwarding methods. Embedding is all-or-nothing: you cannot promote a subset, and you cannot un-promote one later without breaking callers. Two three-line methods buy you a surface you chose deliberately.

Embedding is like putting a colleague's desk phone on your own desk under your name: every call they could take, callers now place with you. A named field is keeping their number in a drawer and passing on only the calls you choose.

saying these in an interview costs you the question

  • Calls embedding inheritance and talks about subclasses
  • Thinks an embedded field's methods stay private to the outer package
  • Believes a type must declare that it implements an interface
  • Assumes embedding copies fields but not methods
  • Expects to promote only some of the embedded type's methods
open as a page

Why does os.Open return (*os.File, error) instead of throwing an exception when the file is missing?

level: juniorimportance: must knowfreq 88%

basics

~20 s

In Go a failure is an ordinary return value of the predeclared error interface type. os.Open hands back a file and an error together, so the caller checks it on the spot and every failure path stays visible.

open as a page

When a Go function returns a non-nil error, what may the caller assume about its other return values?

level: middleimportance: must knowfreq 68%

basics

~20 s

Nothing useful, unless the documentation says otherwise. By convention the other results are their zero values on failure, so a returned pointer is nil. Using one before returning is the classic nil dereference panic in Go code.

open as a page

In Go, why is `func Handle[T any](v T)` no more capable than `func Handle(v any)`?

level: middleimportance: must knowfreq 60%

basics

~20 s

A type parameter constrained by any permits no operations — not even ==. With T appearing in only one position it links nothing to nothing, so the generic form does exactly what the plain any parameter does, with more syntax.

open as a page

Why does Go make every numeric conversion explicit, and what does that still not catch?

level: middleimportance: must knowfreq 62%

basics

~20 s

Go performs no implicit numeric conversion: mixing an int with an int64, or an int with a time.Duration, is a compile error until you write the conversion. But the conversion you wrote can still truncate or lose precision silently.

open as a page

In Go, what does `func Max[T cmp.Ordered](a, b T) T` buy over a version taking `any`?

level: juniorimportance: should knowfreq 52%

basics

~10 s

A type parameter keeps the caller's concrete type: Max returns T rather than any, so the compiler rejects unorderable or mismatched arguments and the caller never writes a type assertion.

open as a page

Why does Go have no ternary operator, and what do you write instead?

level: juniorimportance: should knowfreq 55%

basics

~20 s

Go omits the ?: ternary because it was too often used to build dense, nested expressions. Idiomatic Go declares the variable and follows it with a short if/else; the min and max builtins cover the common comparison case.

open as a page

What does embedding `sync.Mutex` in an exported Go struct expose to that package's importers?

level: middleimportance: should knowfreq 55%

basics

~10 s

It exports the lock itself. Promotion puts Lock, Unlock and TryLock on the struct, so any importing package can lock and unlock your type's internal mutex. Use an unexported named field, mu sync.Mutex, instead.

open as a page

Why does Go's compiler reject an unused local variable but accept an unchecked returned error?

level: middleimportance: should knowfreq 44%

basics

~20 s

An error is an ordinary return value, not a contract the compiler tracks. Go rejects unused variables and imports as build hygiene, but it deliberately has no checked exceptions and no must-use rule, so discarding a returned error compiles cleanly.

open as a page

You add a second embedded type to a Go struct and importers' calls stop compiling - why?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Both embedded types declare the same method name at the same depth, so neither is promoted. The ambiguous selector is rejected where it is used, in the importer's code rather than yours. Fix it with a method on the outer type.

open as a page

A shared Go generic pipeline helper keeps gaining type parameters and two teams copied it back into their own packages. How do you judge whether to keep it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Judge it by real call sites and by what it encodes. A helper that gains a type parameter for each new caller captures a coincidence, not a rule, so delete it and let each pipeline keep its own short loop.

open as a page

With no sum types in Go, how do you model a closed set of variants safely?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Go has no sum types, so a closed variant set is a convention rather than a compiler guarantee. Integer constants with iota model a flat enum; an interface with an unexported method seals a family of types. Neither checks exhaustiveness.

open as a page

A team proposes a shared Go generics utility package. As tech lead, how do you decide?

level: principalimportance: should knowfreq 33%

basics

~20 s

Decide on ownership and cost, not elegance. Ask what slices, maps and cmp already cover, who will own and review the package, what Go version floor it imposes on importers, and how a helper gets removed.

open as a page

Why does Go omit operator overloading, and what does that cost numeric code?

level: middleimportance: nice to knowfreq 35%

basics

~20 s

Operators in Go are fixed by the language: + and < work only on built-in types, and == is a compiler-defined field-wise comparison. Custom types express arithmetic and equality as ordinary methods, which makes numeric code verbose.

open as a page

A Go proxy's io.Copy to the client fails mid-body after http.ResponseWriter.WriteHeader — what does the returned error let you do that stack unwinding would not?

level: seniorimportance: nice to knowfreq 33%

basics

~20 s

It lets the hop that failed act while it still has the context: stop the copy, close the upstream, and record how many bytes io.Copy relayed. Unwinding to one handler loses where the failure happened.

open as a page

A Go type parameter is claimed to be faster than an interface parameter — how would you check?

level: seniorimportance: nice to knowfreq 31%

basics

~20 s

Measure both. Benchmark the two signatures with go test -bench and -benchmem, then compare the compiler's decisions from go build -gcflags=-m. Go compiles generic code per GC shape with a dictionary, so a type parameter does not automatically remove indirection.

open as a page

How do you decide whether an exported Go struct in a shared library may embed a type at all?

level: principalimportance: nice to knowfreq 32%

basics

~20 s

Ask whether you would hand-write and document every promoted method as your own API. If not, hold the dependency in a named field and forward the calls you mean. Embedding publishes a surface you cannot withdraw.

open as a page