gopherpath
0%

Structs, embedding, and interfaces nobody declares

45 min · lesson 1 of 2 · #types · #interfaces · #embedding

A Go type is data plus methods. There is no class, no constructor, no protected, no vtable you can inspect, no extends, no super. Everything you built with inheritance you now build with embedding and interfaces.

Struct + methods vs class

Go
package billing

import "fmt"

type Invoice struct {
	ID     string
	Amount int64 // cents; Go has no BigDecimal in the stdlib — you choose the representation
}

// Value receiver: works on a copy. Use for small, immutable-ish types.
func (i Invoice) Display() string {
	return fmt.Sprintf("%s: %d.%02d", i.ID, i.Amount/100, i.Amount%100)
}

// Pointer receiver: can mutate. Methods are just functions with a receiver.
func (i *Invoice) ApplyDiscount(pct int) {
	i.Amount -= i.Amount * int64(pct) / 100
}

// No constructor keyword. A plain function is the idiom, and it validates.
func NewInvoice(id string, amount int64) (*Invoice, error) {
	if amount < 0 {
		return nil, fmt.Errorf("negative amount %d", amount)
	}
	return &Invoice{ID: id, Amount: amount}, nil
}
Java
public class Invoice {
    private final String id;
    private long amount; // cents

    public Invoice(String id, long amount) {
        if (amount < 0) throw new IllegalArgumentException("negative amount " + amount);
        this.id = id;
        this.amount = amount;
    }

    public String display() { return "%s: %.2f".formatted(id, amount / 100.0); }
    public void applyDiscount(int pct) { amount -= amount * pct / 100; }
}
TypeScript
// closest analogue: a plain object + free functions. Go deliberately has no `class` culture.
export type Invoice = { id: string; amount: number };

export const newInvoice = (id: string, amount: number): Invoice => {
  if (amount < 0) throw new Error(`negative amount ${amount}`);
  return { id, amount };
};

export const applyDiscount = (i: Invoice, pct: number): Invoice => ({
  ...i,
  amount: i.amount - Math.trunc((i.amount * pct) / 100),
});

Note what disappeared: this, field visibility keywords, a constructor, and inheritance. Note what appeared: an error return instead of throw (module 03 is entirely about why).

Interfaces: the part that changes how you design

In Java, implements is a declaration at the type. In Go, an interface is satisfied by having the methods — the implementation does not know the interface exists.

Go
package main

import "fmt"

type Notifier interface {
	Notify(msg string) error
}

type EmailClient struct{ from string }

func (e EmailClient) Notify(msg string) error { // no "implements" anywhere
	fmt.Println("email:", msg)
	return nil
}

type AuditLog struct{}

func (AuditLog) Notify(msg string) error { fmt.Println("audit:", msg); return nil }

func Alert(n Notifier, msg string) error { return n.Notify(msg) }

func main() {
	Alert(EmailClient{from: "billing@example.com"}, "invoice sent")
	Alert(AuditLog{}, "invoice sent")
}
Java
interface Notifier { void notify(String msg); }

class EmailClient implements Notifier {          // explicit, nominal
    public void notify(String msg) { System.out.println("email: " + msg); }
}

class AuditLog implements Notifier {
    public void notify(String msg) { System.out.println("audit: " + msg); }
}
TypeScript
// TypeScript is structurally typed too — this is why TS maps to Go better than Java does.
interface Notifier { notify(msg: string): void }

const email = (from: string): Notifier => ({ notify: (m) => console.log('email:', m) });
const audit: Notifier = { notify: (m) => console.log('audit:', m) };

Three consequences you should adopt immediately:

  1. Declare interfaces where you consume them, not where you implement them. billing does not export Notifier; the caller's package declares the 1–2 methods it needs. A Java habit that bugs Go reviewers is an interface with 12 methods in a service package that every impl must satisfy.
  2. Small is normal. io.Reader is one method. If your interface has more than three methods, ask whether it is two interfaces.
  3. Return structs, accept interfaces. func New() *Store and func Serve(db DB). It keeps the dependency surface minimal.

Embedding is not inheritance

Go
package main

import "fmt"

type Base struct{ ID string }

func (b Base) Describe() string { return "id=" + b.ID }

type Customer struct {
	Base           // embedded: promoted methods, but NOT a subtype
	Name string
}

func main() {
	c := Customer{Base: Base{ID: "c-42"}, Name: "ACME"}
	fmt.Println(c.Describe()) // promoted from Base
	fmt.Println(c.ID, c.Name)
	// You cannot pass c where a Base is expected — no subtyping, no override-super.
}

If you want the Java behaviour, you write it explicitly: a field, and a delegating method. Being forced to write the delegation is the feature — deep hierarchies are how Java codebases rot, and Go simply does not let you build them.

Trap: embedding sync.Mutex in a struct promotes Lock/Unlock to your public API, and it is a data race waiting to happen once the struct is copied. Embed deliberately; prefer a named field.

Checkpoint

  1. Define type Money int64 and give it Add(Money) Money and String() string. Note that String() makes it satisfy fmt.Stringer without importing anything — that is implicit interface satisfaction in the wild.
  2. Take your invoice service from M0.02. Change the handler to accept an InvoiceStore interface with two methods, declared in the HTTP package, implemented by an in-memory struct in a store package. No mock library.
  3. Add a Customer that embeds Invoice's type and overrides nothing, then try passing Customer to func f(i Invoice). Read the compiler error — it will teach you the difference between subtyping and embedding in one line.
stored in this browser only