If you've worked with class inheritance long enough, you’ve probably reached a point where it starts feeling frustrating and restrictive. Then we all know and can appreciate how Object Composition patterns can simplify code and make express behavior of code better. This is not a write up on Composition vs Inheritance, for more on that take a look at Composition over Inheritance Composition over Inheritance Instead of thinking in terms of “A is a B,” composition lets us think in terms of “A has behavior X”. This approach makes our code more modular, flexible, and reusable. Instead of thinking in terms of “A is a B,” composition lets us think in terms of “A has behavior X”. This approach makes our code more modular, flexible, and reusable. “A has behavior X” Go is not an object-oriented language. Staying true to its "Keep it Simple" philosophy, Go does not support classes, objects, or inheritance. object-oriented "Keep it Simple" "Keep it Simple" Interface Composition in TypeScript In Typescript we can leverage type intersections (&) to compose types, building types with all the features we need. type intersections ( & Let’s say we’re working with animals. Some animals live on land, some in water, and some (like amphibians) can do both. Instead of using inheritance to model this, we can compose behaviors: type Animal = { sound: () => string } type AquaticAnimal = Animal & { swim: () => void } type LandAnimal = Animal & { walk: () => void } type Amphibian = AquaticAnimal & LandAnimal const dolphin: AquaticAnimal = { sound() { return 'ee ee ee' }, swim() { console.log('🐬 🐬 🐬') }, } const alligator: Amphibian = { sound() { return 'sssss' }, swim() { console.log('💦 💦 💦') }, walk() { console.log('🐊 🐊 🐊') }, } console.log((alligator as Animal).sound()) // Works fine console.log((dolphin as LandAnimal).walk()) // TypeScript will warn you! type Animal = { sound: () => string } type AquaticAnimal = Animal & { swim: () => void } type LandAnimal = Animal & { walk: () => void } type Amphibian = AquaticAnimal & LandAnimal const dolphin: AquaticAnimal = { sound() { return 'ee ee ee' }, swim() { console.log('🐬 🐬 🐬') }, } const alligator: Amphibian = { sound() { return 'sssss' }, swim() { console.log('💦 💦 💦') }, walk() { console.log('🐊 🐊 🐊') }, } console.log((alligator as Animal).sound()) // Works fine console.log((dolphin as LandAnimal).walk()) // TypeScript will warn you! Why This Works Well Why This Works Well No rigid hierarchies – Instead of a deep class tree, we mix and match behaviors. Flexibility – We can create new types by combining existing ones, without modifying the originals. Compile-time safety – TypeScript prevents us from calling walk() on an AquaticAnimal. No rigid hierarchies – Instead of a deep class tree, we mix and match behaviors. No rigid hierarchies Flexibility – We can create new types by combining existing ones, without modifying the originals. Flexibility Compile-time safety – TypeScript prevents us from calling walk() on an AquaticAnimal. Compile-time safety walk() AquaticAnimal But what about Go? Since Go doesn’t have classes or inheritance, how does it handle composition? Interface Composition in Go How would we go about achieving this in Go you ask? In Go, you can embed interfaces inside each other, kind of like snapping Lego blocks together to build something bigger. This makes it easy to break down complex ideas into smaller, more manageable pieces. Since we're composing functionality instead of stacking up rigid inheritance chains, we get way more flexibility and avoid those fragile, complicated class hierarchies. Plus, embedding means less copy-pasting, so we keep our code DRY ☂️ without extra hassle. DRY package main import "fmt" // Define Animal behavior type Animal interface { Sound() string } // Define Aquatic behavior type AquaticAnimal interface { Animal Swim() string } // Define Land behavior type LandAnimal interface { Animal Walk() string } // Amphibian embeds both AquaticAnimal and LandAnimal type Amphibian interface { AquaticAnimal LandAnimal } // Dolphin implements AquaticAnimal type Dolphin struct{} func (d Dolphin) Swim() string { return "🐬 🐬 🐬" } func (d Dolphin) Sound() string { return "ee ee ee" } // Crocodile implements Amphibian (both swimming and walking) type Crocodile struct{} func (c Crocodile) Swim() string { return "💦 💦 💦" } func (c Crocodile) Walk() string { return "🐊 🐊 🐊" } func (c Crocodile) Sound() string { return "argh" } func main() { // Create instances willy := Dolphin{} ticktock := Crocodile{} // Call behaviors fmt.Println(willy.Sound()) // ee ee ee fmt.Println(willy.Swim()) // 🐬 🐬 🐬 fmt.Println(ticktock.Sound()) // argh fmt.Println(ticktock.Swim()) // 💦 💦 💦 fmt.Println(ticktock.Walk()) // 🐊 🐊 🐊 // Using interface values var animal Animal = willy fmt.Println(animal.Sound()) // ee ee ee animal = ticktock fmt.Println(animal.Sound()) // argh } package main import "fmt" // Define Animal behavior type Animal interface { Sound() string } // Define Aquatic behavior type AquaticAnimal interface { Animal Swim() string } // Define Land behavior type LandAnimal interface { Animal Walk() string } // Amphibian embeds both AquaticAnimal and LandAnimal type Amphibian interface { AquaticAnimal LandAnimal } // Dolphin implements AquaticAnimal type Dolphin struct{} func (d Dolphin) Swim() string { return "🐬 🐬 🐬" } func (d Dolphin) Sound() string { return "ee ee ee" } // Crocodile implements Amphibian (both swimming and walking) type Crocodile struct{} func (c Crocodile) Swim() string { return "💦 💦 💦" } func (c Crocodile) Walk() string { return "🐊 🐊 🐊" } func (c Crocodile) Sound() string { return "argh" } func main() { // Create instances willy := Dolphin{} ticktock := Crocodile{} // Call behaviors fmt.Println(willy.Sound()) // ee ee ee fmt.Println(willy.Swim()) // 🐬 🐬 🐬 fmt.Println(ticktock.Sound()) // argh fmt.Println(ticktock.Swim()) // 💦 💦 💦 fmt.Println(ticktock.Walk()) // 🐊 🐊 🐊 // Using interface values var animal Animal = willy fmt.Println(animal.Sound()) // ee ee ee animal = ticktock fmt.Println(animal.Sound()) // argh } Real world examples Go's standard library provides several fundamental interface types that form the backbone of its I/O and string-handling capabilities. Some key examples include: fmt.Stringer defines a String() method, allowing a type to represent itself as a string. io.Reader reads data into a byte slice, commonly used for streaming input. io.Writer writes a byte slice to an output destination, such as a file or network connection. fmt.Stringer defines a String() method, allowing a type to represent itself as a string. fmt.Stringer String() io.Reader reads data into a byte slice, commonly used for streaming input. io.Reader io.Writer writes a byte slice to an output destination, such as a file or network connection. io.Writer These interfaces are widely implemented across the standard library and third-party libraries, enabling seamless integration with files, network I/O, custom serializers, and more. A great example is net.Conn from the net package, which implements both io.Reader and io.Writer: net.Conn net io.Reader io.Writer As an io.Reader, net.Conn allows you to call Read() to receive incoming data. As an io.Writer, it supports Write(), enabling data transmission over the connection. As an io.Reader, net.Conn allows you to call Read() to receive incoming data. io.Reader net.Conn Read() As an io.Writer, it supports Write(), enabling data transmission over the connection. io.Writer Write() This dual implementation makes net.Conn a powerful tool for handling network communication efficiently. net.Conn Conclusion Conclusion Composition helps us avoid deep, rigid hierarchies and instead focus on what objects can do. avoid deep, rigid hierarchies what objects can do In TypeScript, we achieve composition using type intersections (&), allowing us to mix and match behaviors. In Go, we use interface embedding, making it easy to compose functionality without inheritance. Go’s standard library is built on interface composition, making it highly modular and reusable. In TypeScript, we achieve composition using type intersections (&), allowing us to mix and match behaviors. TypeScript type intersections ( & In Go, we use interface embedding, making it easy to compose functionality without inheritance. Go interface embedding Go’s standard library is built on interface composition, making it highly modular and reusable. standard library By thinking in terms of "has-a" instead of "is-a", we can write cleaner, more maintainable code across different programming languages. "has-a" instead of "is-a" Next Steps: Try refactoring some of your own code using composition instead of inheritance. You’ll be surprised at how much cleaner and more flexible it becomes! Next Steps: Bonus question Will it compile? If not, why? Drop your answers in the comments! 🤔 Will it compile? If not, why? Drop your answers in the comments! 🤔 package main type A interface { sayHi() string } type B interface { sayBye() string } type C interface { A B } func main() { } package main type A interface { sayHi() string } type B interface { sayBye() string } type C interface { A B } func main() { } Further Reading https://www.thoughtworks.com/en-de/insights/blog/programming-languages/mistakes-to-avoid-when-coming-from-an-object-oriented-language https://rauljordan.com/using-interface-composition-in-go-as-guardrails/ https://www.thoughtworks.com/en-de/insights/blog/programming-languages/mistakes-to-avoid-when-coming-from-an-object-oriented-language https://www.thoughtworks.com/en-de/insights/blog/programming-languages/mistakes-to-avoid-when-coming-from-an-object-oriented-language https://rauljordan.com/using-interface-composition-in-go-as-guardrails/ https://rauljordan.com/using-interface-composition-in-go-as-guardrails/ Hope you enjoyed reading this as much as I enjoyed writing it. Hope you enjoyed reading this as much as I enjoyed writing it. If this helped you think differently about composition, share it with a fellow developer. Also, if you’ve got thoughts or questions, drop them in the comments—I’d love to discuss more!