Go's Type System - Structs, Interfaces, and Life Without Inheritance
DEV Community

Go's Type System - Structs, Interfaces, and Life Without Inheritance

Go's Type System - Structs, Interfaces, and Life Without Inheritance In part 1 of this series I talked about why I'm picking up Go after six years of Java and Kotlin, plus a recent deep dive into Rust. This time I want to get into the part that actually changed how I think about designing code: Go has no class inheritance at all. Coming from the JVM world, that sentence sounded alarming the first time I read it. No extends . No abstract classes. No polymorphism through a class hierarchy. And yet Go backends at companies running serious scale seem to do just fine without it. After a few weeks living inside Go's type system, I get why. Structs: Data, Nothing More A Go struct is just a typed bag of fields. No constructors, no access modifiers in the Java sense, no inheritance: type Order struct { ID string Customer string Amount float64 Status string } func NewOrder ( id , customer string , amount float64 ) Order { return Order { ID : id , Customer : customer , Amount : amount , Status : "pending" , } } That NewOrder function is doing the job a constructor would do in Java - it's just a plain function by convention, not a language feature. Nothing stops you from building an Order{} directly with zero values either, which takes some adjusting to if you're used to constructors enforcing invariants. Methods attach to structs separately, outside the type definition: func ( o Order ) Total () float64 { return o . Amount } func ( o * Order ) MarkPaid () { o . Status = "paid" } That (o Order) vs (o *Order) distinction is the receiver type, and it trips up a lot of newcomers. A value receiver gets a copy of the struct; a pointer receiver can mutate the original. MarkPaid has to use a pointer receiver, or the status change would vanish the moment the method returns. No Inheritance, So What Replaces It? This is the part that took the most rewiring. In Java, if PremiumOrder needed everything Order had plus more, you'd write class PremiumOrder extends Order . Go simply doesn't have that mechanism. Instead, you embed: type PremiumOrder struct { Order Priority int } Embedding Order inside PremiumOrder promotes its fields and methods up to the outer struct. So premiumOrder.Total() and premiumOrder.Status just work, as if they were defined directly on PremiumOrder . It looks like inheritance from the outside, but it's composition under the hood - PremiumOrder has an Order , it doesn't become one. That distinction matters the moment you need polymorphism, which is where interfaces take over. Interfaces: The Part That Actually Surprised Me Here's the genuinely different idea in Go's type system. In Java or Kotlin, a class declares upfront which interfaces it implements - class PaymentProcessor implements Refundable . The relationship is explicit and lives at the definition site. In Go, interfaces are satisfied implicitly. There's no implements keyword at all: type Refundable interface { Refund ( amount float64 ) error } type CardPayment struct { TransactionID string } func ( c CardPayment ) Refund ( amount float64 ) error { fmt . Printf ( "Refunding %.2f for transaction %s \n " , amount , c . TransactionID ) return nil } CardPayment never mentions Refundable anywhere. It just happens to have a Refund(amount float64) error method, so it satisfies the interface automatically. Any type, anywhere in the codebase - even one you didn't write - can satisfy an interface it never knew existed: func processRefund ( r Refundable , amount float64 ) error { return r . Refund ( amount ) } processRefund ( CardPayment { TransactionID : "txn_123" }, 49.99 ) The first time I saw this, it felt like it would lead to chaos - how do you know what implements what? But in practice it flips the design direction in a useful way. Interfaces in Go tend to be small and defined by the consumer , not the producer. You don't design a sprawling PaymentProcessor interface upfront and force every payment type to conform to it. You write the function that needs a Refund method, declare a one-method interface for exactly that, and let any matching type plug in. The standard library leans hard into this - io.Reader and io.Writer are each a single method, and half of Go's ecosystem is built by composing those tiny interfaces together. Putting It Together: Composition Over Inheritance, For Real The Java-world mantra "favor composition over inheritance" is something I nodded along to for years while still reaching for extends out of habit. Go doesn't give you the option to reach for it - composition is the only tool on the table, so you actually use it. A small example of how this plays out: instead of a BasePaymentHandler abstract class with CardHandler and BankTransferHandler subclasses overriding behavior, you'd define the shape you need as an interface and write independent structs that satisfy it: type PaymentHandler interface { Process ( amount float64 ) error } type CardHandler struct {} func ( CardHandler ) Process ( amount float64 ) error { fmt . Println ( "P

Structs: Data, Nothing More

A Go struct is just a typed bag of fields. No constructors, no access modifiers in the Java sense, no inheritance:

type Order struct {
    ID       string
    Customer string
    Amount   float64
    Status   string
}

func NewOrder(id, customer string, amount float64) Order {
    return Order{
        ID:       id,
        Customer: customer,
        Amount:   amount,
        Status:   "pending",
    }
}

That NewOrder function is doing the job a constructor would do in Java - it's just a plain function by convention, not a language feature. Nothing stops you from building an Order{} directly with zero values either, which takes some adjusting to if you're used to constructors enforcing invariants.

Methods attach to structs separately, outside the type definition:

func (o Order) Total() float64 {
    return o.Amount
}

func (o *Order) MarkPaid() {
    o.Status = "paid"
}

That (o Order) vs (o *Order) distinction is the receiver type, and it trips up a lot of newcomers. A value receiver gets a copy of the struct; a pointer receiver can mutate the original. MarkPaid has to use a pointer receiver, or the status change would vanish the moment the method returns.

No Inheritance, So What Replaces It?

This is the part that took the most rewiring. In Java, if PremiumOrder needed everything Order had plus more, you'd write class PremiumOrder extends Order. Go simply doesn't have that mechanism. Instead, you embed:

type PremiumOrder struct {
    Order
    Priority int
}

Embedding Order inside PremiumOrder promotes its fields and methods up to the outer struct. So premiumOrder.Total() and premiumOrder.Status just work, as if they were defined directly on PremiumOrder. It looks like inheritance from the outside, but it's composition under the hood - PremiumOrder has an Order, it doesn't become one. That distinction matters the moment you need polymorphism, which is where interfaces take over.

Interfaces: The Part That Actually Surprised Me

Here's the genuinely different idea in Go's type system. In Java or Kotlin, a class declares upfront which interfaces it implements - class PaymentProcessor implements Refundable. The relationship is explicit and lives at the definition site. In Go, interfaces are satisfied implicitly. There's no implements keyword at all:

type Refundable interface {
    Refund(amount float64) error
}

type CardPayment struct {
    TransactionID string
}

func (c CardPayment) Refund(amount float64) error {
    fmt.Printf("Refunding %.2f for transaction %s\n", amount, c.TransactionID)
    return nil
}

CardPayment never mentions Refundable anywhere. It just happens to have a Refund(amount float64) error method, so it satisfies the interface automatically. Any type, anywhere in the codebase - even one you didn't write - can satisfy an interface it never knew existed:

func processRefund(r Refundable, amount float64) error {
    return r.Refund(amount)
}

processRefund(CardPayment{TransactionID: "txn_123"}, 49.99)

The first time I saw this, it felt like it would lead to chaos - how do you know what implements what? But in practice it flips the design direction in a useful way. Interfaces in Go tend to be small and defined by the consumer, not the producer. You don't design a sprawling PaymentProcessor interface upfront and force every payment type to conform to it. You write the function that needs a Refund method, declare a one-method interface for exactly that, and let any matching type plug in. The standard library leans hard into this - io.Reader and io.Writer are each a single method, and half of Go's ecosystem is built by composing those tiny interfaces together.

Putting It Together: Composition Over Inheritance, For Real

The Java-world mantra "favor composition over inheritance" is something I nodded along to for years while still reaching for extends out of habit. Go doesn't give you the option to reach for it - composition is the only tool on the table, so you actually use it.

A small example of how this plays out: instead of a BasePaymentHandler abstract class with CardHandler and BankTransferHandler subclasses overriding behavior, you'd define the shape you need as an interface and write independent structs that satisfy it:

type PaymentHandler interface {
    Process(amount float64) error
}

type CardHandler struct{}

func (CardHandler) Process(amount float64) error {
    fmt.Println("Processing card payment:", amount)
    return nil
}

type BankTransferHandler struct{}

func (BankTransferHandler) Process(amount float64) error {
    fmt.Println("Processing bank transfer:", amount)
    return nil
}

func charge(h PaymentHandler, amount float64) error {
    return h.Process(amount)
}

No shared base class, no super.Process() calls, no fragile hierarchy to untangle later when a new payment type doesn't quite fit the existing tree. Each handler is independent; the interface is just a contract describing what charge needs.

Where This Still Feels Unfamiliar

A few things haven't fully clicked yet, and I'd rather be honest about that than pretend the transition was seamless:

  • Implicit satisfaction makes "what implements this?" harder to answer. In an IDE-heavy Java workflow, "find implementations" is one click. In Go, you're often grepping for method signatures, since there's no declared relationship to search for. Tooling helps, but it's a different mental habit.

  • Embedding isn't inheritance, and pretending otherwise causes bugs. It's tempting to treat PremiumOrder embedding Order as "PremiumOrder is an Order," but method promotion doesn't give you real polymorphism - you can't pass a PremiumOrder somewhere that explicitly expects an Order type. It works at the field/method-access level only.

  • Zero values mean "uninitialized" can silently look valid. A struct field you forgot to set isn't null and isn't an error - it's just the zero value ("", 0, false). That's elegant in some ways and a quiet source of bugs in others, especially coming from a world where constructors enforce required fields.

Up Next

This composition-first, implicit-interface design is going to matter a lot once concurrency enters the picture, since goroutines and channels lean on small interfaces too. In part 3, I'm digging into goroutines and channels - and where Go's concurrency model genuinely outperforms the thread-pool mental model I'm used to from the JVM, plus where it bites if you're not careful.

If you've made the jump from Java, Kotlin, or another OOP-heavy language to Go, I'm curious - did implicit interfaces feel natural to you immediately, or did it take a few weeks to stop reaching for implements?

Comments

No comments yet. Start the discussion.