Go Interfaces Concept Lab

Learn Go interfaces by writing code with and without them.

Introduction

30 Second Summary

Think about a power outlet. It doesn't care whether you plug in a lamp, a phone charger, or a blender. It only cares that the plug fits.

In this project, you will build five small Go programs that progressively teach you how interfaces work. You will start by writing code without interfaces, feel the pain of repetition, then refactor and experience why they exist.

What You'll Build

A collection of self-contained Go programs, each isolating one core interface concept so you can run, break, and rebuild your understanding one piece at a time.

By the end of this project, you'll have:

  • Five runnable programs covering implicit implementation, polymorphism, value vs pointer receivers, and interface composition.
  • A clear mental model of when and why to use interfaces in your own Go code.
  • The experience of adding a new type without changing any existing code.
  • Secret Mission: Build a mini shape calculator using type switches and the empty interface to handle unknown types gracefully.

Are there any prerequisites?

You should be comfortable with basic Go concepts like structs, methods, functions, and packages. If you can define a struct and attach a method to it, you have everything you need to start.

Set Up Your Project

Before writing any interface code, you need a clean workspace with a proper Go module and an organized folder structure.

Each subdirectory in this project holds a self-contained program that teaches exactly one interface concept. By the end of this step, your project skeleton will be ready for code.

In this step, get ready to:
  • Create the go-interfaces-lab project directory.
  • Initialize a Go module.
  • Create subdirectories for each concept program.
Create your project directory and initialize a Go module
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir go-interfaces-lab
cd go-interfaces-lab
  • Confirm you are inside the new folder by running:
ls

You should see an empty directory with no files listed yet.

Seeing 'directory already exists'?

If the folder already exists from a previous attempt, delete it first with rm -rf go-interfaces-lab from your Desktop, then re-run the mkdir and cd commands above.

Help me figure out what went wrong with creating my project directory.

  • Initialize a Go module by running:
go mod init go-interfaces-lab

You should see go: creating new go.mod: module go-interfaces-lab in your terminal output.

What is a Go module?

A Go module is the unit of dependency management in Go. The go mod init command creates a go.mod file that tracks your module name and the minimum Go version required to build it.

Since this project uses only the standard library, go.mod will stay simple with no external dependencies listed.

Command not found?

If your terminal does not recognize go, make sure Go is installed and your PATH includes the Go binary directory.

Run go version to verify your installation. You should see a version number starting with go1. in the output.

Help me troubleshoot why my terminal cannot find the Go command.

Create the concept subdirectories
  • Create the five subdirectories for each concept program by running:
mkdir 01_the_problem 02_first_interface 03_polymorphism 04_receivers 05_composition
  • Confirm the directories exist by running:
ls

You should see 01_the_problem, 02_first_interface, 03_polymorphism, 04_receivers, 05_composition, and go.mod listed in the output.

  • Verify the contents of go.mod by running:
cat go.mod

You should see module go-interfaces-lab on the first line and go 1.25.0 on the third line.

Why does it say go 1.25.0?

The Go 1.26 toolchain writes go 1.25.0 as the default version in new modules. This is the minimum Go version required to build your module, not the version you have installed.

✔️ Awesome, I've got everything!

Great. Double check you have all five subdirectories and go.mod present in go-interfaces-lab.

ⓧ I'd like to double check the full code

Your go.mod file should look exactly like this:

module go-interfaces-lab

go 1.25.0

Your project skeleton is ready. Next up, you will write your first Go program and experience the problem that interfaces solve.

Feel the Pain (Life Without Interfaces)

Your project structure is ready. Now it's time to write some Go code and experience a real design problem firsthand.

In this step, you will write code the way many beginners do: with separate functions for each type. By the end, you will feel exactly why this approach breaks down as your codebase grows.

In this step, get ready to:
  • Define two animal structs with a Speak() method each.
  • Write separate print functions for each animal type.
  • Identify the scaling problem this creates.
Create the animal structs without interfaces

You are going to define two animal types that can each produce a sound. Both types will have the exact same method signature, but the code calling them will not be able to treat them the same way.

  • In VS Code's Explorer sidebar, expand the 01_the_problem folder.
  • Right-click inside that folder and select New File.
  • Name the file main.go and press Enter.
  • Paste the following code into 01_the_problem/main.go:
package main

import "fmt"

type Dog struct {
	Name string
}

type Cat struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says: Woof!"
}

func (c Cat) Speak() string {
	return c.Name + " says: Meow!"
}

What does this code do?

You defined two structs, Dog and Cat, each with a Name field.

Each struct has a Speak() method that returns a string with the animal's sound. Notice both methods have the exact same signature: they take no arguments and return a string. This will matter later.

  • Now add the print functions and main() below the existing code:
func printDogSound(d Dog) {
	fmt.Println(d.Speak())
}

func printCatSound(c Cat) {
	fmt.Println(c.Speak())
}

func main() {
	dog := Dog{Name: "Rex"}
	cat := Cat{Name: "Whiskers"}

	printDogSound(dog)
	printCatSound(cat)
}

What does this code do?

You wrote two separate functions: printDogSound takes a Dog and printCatSound takes a Cat. Both functions do the same thing: call Speak() and print the result.

The main() function creates one Dog named "Rex" and one Cat named "Whiskers", then calls each dedicated print function.

  • Save 01_the_problem/main.go.
  • Run the program by entering this command in your terminal:
go run 01_the_problem/main.go
  • You should see this output:
Rex says: Woof!
Whiskers says: Meow!

Not seeing the expected output?

  • Make sure you saved the file before running the command.
  • Check that you are running the command from the go-interfaces-lab directory, not from inside 01_the_problem.
  • If you see a syntax error, double-check that every opening brace { has a matching closing brace }.

Why is my Go program not compiling?

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete 01_the_problem/main.go file so far (without the comment you will add next):

package main

import "fmt"

type Dog struct {
	Name string
}

type Cat struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says: Woof!"
}

func (c Cat) Speak() string {
	return c.Name + " says: Meow!"
}

func printDogSound(d Dog) {
	fmt.Println(d.Speak())
}

func printCatSound(c Cat) {
	fmt.Println(c.Speak())
}

func main() {
	dog := Dog{Name: "Rex"}
	cat := Cat{Name: "Whiskers"}

	printDogSound(dog)
	printCatSound(cat)
}
Recognize the scaling problem

Look at printDogSound and printCatSound. They do the exact same thing. The only difference is the type in the parameter list.

Now imagine adding a Bird, a Fish, a Horse, and 97 more animals. You would need 100 nearly identical print functions. This does not scale.

  • Add this comment at the very bottom of 01_the_problem/main.go, after the closing brace of main():
// PROBLEM: Every new animal needs its own print function. What if we had 100 animals?

Why leave this comment?

This comment marks the exact pain point that interfaces solve. The calling code (your print functions) should not care what specific animal it is talking to. It only cares that the animal can Speak().

In the next step, you will collapse both print functions into a single function that accepts anything with a Speak() method.

  • Save 01_the_problem/main.go.

You can feel the repetition. In the next step, you will fix this problem with a single interface.

Write Your First Interface

In the last step, you felt the pain of writing a separate print function for every animal type. If you had 100 animals, you'd need 100 nearly identical functions. That kind of repetition is exactly what interfaces solve.

An interface in Go defines a set of method signatures. Any type that has those methods automatically satisfies the interface. No implements keyword needed.

In this step, get ready to:
  • Define a Speaker interface that any animal can satisfy.
  • Write a single printSound function that works with any Speaker.
  • Add a new animal type without changing existing code.
Define the Speaker interface and animal types

The fix starts with defining a contract. A Speaker interface says: "any type that has a Speak() string method counts as a Speaker." Dog and Cat already have that method from the previous step.

  • Create a new file at 02_first_interface/main.go and paste in the interface definition, structs, and methods:
package main

import "fmt"

// Speaker defines the contract: any type with a Speak() method is a Speaker
type Speaker interface {
	Speak() string
}

// Dog satisfies Speaker because it has Speak() string
type Dog struct {
	Name string
}

// Cat satisfies Speaker because it has Speak() string
type Cat struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says: Woof!"
}

func (c Cat) Speak() string {
	return c.Name + " says: Meow!"
}

What does this code do?

The Speaker interface declares one method signature: Speak() string. That's the entire contract.

Dog and Cat each have a Speak() string method. In Go, that's all it takes. They satisfy the Speaker interface automatically. You never write "Dog implements Speaker" anywhere.

You won't see a change yet. The next chunk adds the function that uses this interface.

  • Add the printSound function and main() below the Cat's Speak method:
// printSound accepts ANY Speaker - one function for all types
func printSound(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	dog := Dog{Name: "Rex"}
	cat := Cat{Name: "Whiskers"}

	// Both pass through the SAME function
	printSound(dog)
	printSound(cat)
}

What does this code do?

The printSound(s Speaker) function accepts any value that satisfies the Speaker interface. It doesn't know or care whether it's a Dog or a Cat.

Compare this to the previous step where you needed printDogSound and printCatSound. Two functions collapsed into one.

  • Save 02_first_interface/main.go.
  • Run your program by running this command:
go run 02_first_interface/main.go

You should see:

Rex says: Woof!
Whiskers says: Meow!

Both animals print their sound through the same printSound function. The interface made it possible.

Seeing a compile error?

  • Check that the interface method signature Speak() string matches the method signatures on Dog and Cat exactly. Capitalization matters.
  • Make sure package main is the first line and import "fmt" is included.

Help me debug my Speaker interface code.

Why is there no implements keyword?

Go uses implicit implementation. A type satisfies an interface simply by having all the methods the interface declares. There is no explicit declaration of intent.

This means you can define an interface after the types already exist. You can even define interfaces for types in packages you don't own.

Add a new type without changing existing code

Here's the real test. You'll add a Bird type and pass it to printSound without touching that function at all.

  • In 02_first_interface/main.go, find the Cat's Speak method. Add the Bird struct and its method directly below it (above printSound):
type Bird struct {
	Name string
}

func (b Bird) Speak() string {
	return b.Name + " says: Tweet!"
}
  • Now add a Bird to main(). Find the line printSound(cat) and add these lines below it:
	bird := Bird{Name: "Tweety"}

	printSound(bird)

What just happened?

You added a completely new type and passed it to printSound without changing that function at all. Bird satisfies Speaker because it has a Speak() string method.

This is polymorphism. One function handles multiple types through a shared interface. Adding the 101st animal still requires zero changes to printSound.

  • Save 02_first_interface/main.go.
  • Run the program again:
go run 02_first_interface/main.go

You should see all three animals speaking through the same function:

Rex says: Woof!
Whiskers says: Meow!
Tweety says: Tweet!

Only seeing two animals?

  • Make sure you added the bird := Bird{Name: "Tweety"} line and printSound(bird) inside main(), not outside it.
  • Confirm you saved the file before running the command.

Why isn't my Bird printing?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

package main

import "fmt"

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

type Cat struct {
	Name string
}

type Bird struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says: Woof!"
}

func (c Cat) Speak() string {
	return c.Name + " says: Meow!"
}

func (b Bird) Speak() string {
	return b.Name + " says: Tweet!"
}

func printSound(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	dog := Dog{Name: "Rex"}
	cat := Cat{Name: "Whiskers"}
	bird := Bird{Name: "Tweety"}

	printSound(dog)
	printSound(cat)
	printSound(bird)
}

You've just experienced the core power of interfaces. One function, many types, zero changes when you add something new. Next up, you'll learn the one gotcha that trips up every Go beginner: value vs pointer receivers.

Understand Value vs Pointer Receivers

You have seen how interfaces let you write one function that handles many types. Now comes the most common source of confusion for Go beginners: value receivers vs pointer receivers.

When a method needs to change the struct it belongs to, the receiver type matters. In this step, you will write a method that tries to modify a field, watch it silently fail, then fix it with one character.

In this step, get ready to:
  • Write a value receiver method that silently fails to mutate state.
  • Switch to a pointer receiver and see the fix.
  • Understand why pointer receivers are stricter for interface satisfaction.
Start with a value receiver that silently fails

A value receiver gets a copy of the struct. Any changes the method makes to the copy disappear when the method returns. This is the gotcha you are about to experience firsthand.

  • Create 04_receivers/main.go with the following code:
package main

import "fmt"

type Muter interface {
	Mute()
}

type TV struct {
	Volume int
}

// Value receiver: gets a COPY of the TV struct
func (t TV) Mute() {
	t.Volume = 0
}

func main() {
	tv := TV{Volume: 100}
	tv.Mute()
	fmt.Println("Volume after mute:", tv.Volume)
}

What does this code do?

The Muter interface requires one method: Mute(). The TV struct has a Volume field set to 100.

The Mute() method uses a value receiver (t TV). This means Go passes a copy of the TV struct into the method. Setting t.Volume = 0 modifies the copy, not the original.

  • Run the program by executing this command:
go run 04_receivers/main.go

You should see Volume after mute: 100 in your terminal. The volume did not change.

Why is the volume still 100?

Go passes everything by value. When you call tv.Mute(), Go copies the entire TV struct into the method. The method sets Volume = 0 on that copy. The original tv variable in main() is untouched.

This is the silent failure that catches many Go beginners. The code compiles and runs without errors, but the mutation never sticks.

Fix it with a pointer receiver

The fix is one character: add * before the type name in the receiver. A pointer receiver operates on the original struct, so changes persist.

  • In 04_receivers/main.go, find this line:
func (t TV) Mute() {
  • Replace it with a pointer receiver by adding * before TV, so it looks like this:
func (t *TV) Mute() {

Now replace the rest of the file so it includes the silence function and an updated main(). This function accepts any Muter and calls Mute() on it.

  • Replace the entire contents of 04_receivers/main.go with this final version:
package main

import "fmt"

type Muter interface {
	Mute()
}

type TV struct {
	Volume int
}

func (t *TV) Mute() {
	t.Volume = 0
}

func silence(m Muter) {
	m.Mute()
}

func main() {
	tv := TV{Volume: 100}
	fmt.Println("Before mute:", tv.Volume)

	silence(&tv)
	fmt.Println("After mute:", tv.Volume)
}

What changed and why?

The receiver changed from (t TV) to (t *TV). Now Mute() receives a pointer to the original struct, so t.Volume = 0 modifies the actual tv variable.

The silence(m Muter) function accepts any type that satisfies the Muter interface. You pass &tv (a pointer) because only *TV satisfies Muter when using a pointer receiver.

  • Save 04_receivers/main.go and run it:
go run 04_receivers/main.go

You should see Before mute: 100 followed by After mute: 0. The pointer receiver made the mutation stick.

What happens if you pass tv instead of &tv?

Try changing silence(&tv) to silence(tv) and running again. The compiler rejects it because TV (a plain value) does not satisfy the Muter interface when Mute() has a pointer receiver.

The rule: a value receiver means both T and *T satisfy the interface. A pointer receiver means only *T satisfies it. This is why you must pass &tv to silence.

Change it back to silence(&tv) before moving on.

Seeing a compiler error?

If you see an error mentioning that TV does not implement Muter, check that you are passing &tv (a pointer) to the silence function, not plain tv.

If the volume is still 100, make sure you changed the receiver from (t TV) to (t *TV). The * makes all the difference.

help me fix my value vs pointer receiver issue in Go

✔️ Awesome, I've got everything!

Great. Double check you have saved 04_receivers/main.go and that running it prints Before mute: 100 and After mute: 0.

ⓧ I'd like to double check the full code

package main

import "fmt"

type Muter interface {
	Mute()
}

type TV struct {
	Volume int
}

func (t *TV) Mute() {
	t.Volume = 0
}

func silence(m Muter) {
	m.Mute()
}

func main() {
	tv := TV{Volume: 100}
	fmt.Println("Before mute:", tv.Volume)

	silence(&tv)
	fmt.Println("After mute:", tv.Volume)
}

You now understand the most common gotcha with Go interfaces. Next up, you will implement a real standard library interface and compose small interfaces into larger ones.

Compose Interfaces and Use fmt.Stringer

You have seen how interfaces unify types (Step 3) and how pointer receivers change which types satisfy an interface (Step 4). Now it is time to see how interfaces work in real Go codebases.

Real-world interfaces are small, often just one or two methods. Go builds larger interfaces by composing small ones together. The standard library itself does this everywhere.

In this step, get ready to:
  • Implement the fmt.Stringer interface to control how your types print.
  • Compose small interfaces into a larger one.
  • Add a compile-time safety check that catches interface drift before runtime.
Implement fmt.Stringer on a Player struct

Go's fmt package defines an interface called Stringer. Any type that has a String() string method automatically controls how it appears when passed to fmt.Println.

  • Create a new file at 05_composition/main.go and add the following code:
package main

import "fmt"

// Player holds a name and score
type Player struct {
	Name  string
	Score int
}

// String implements fmt.Stringer so fmt.Println uses our custom format
func (p Player) String() string {
	return fmt.Sprintf("%s [Score: %d]", p.Name, p.Score)
}

func main() {
	p := Player{Name: "Alice", Score: 42}
	fmt.Println(p)
}

What does this code do?

Player is a struct with Name and Score fields. The String() method returns a formatted string like Alice [Score: 42].

You never call String() yourself. When you pass p to fmt.Println, Go detects that Player satisfies the fmt.Stringer interface and calls String() automatically.

  • Save 05_composition/main.go.
  • Run the program to confirm it works:
go run 05_composition/main.go

You should see Alice [Score: 42] printed in your terminal. That output comes from your custom String() method, not Go's default struct formatting.

Seeing the default struct output instead?

If you see {Alice 42} instead of Alice [Score: 42], your String() method signature does not match exactly. Make sure it is func (p Player) String() string with a capital S and no parameters.

help me debug why fmt.Println is not using my String() method

Compose small interfaces into a larger one

Go builds large interfaces from small ones by embedding them. This is exactly how the standard library creates io.ReadWriter from io.Reader and io.Writer.

  • In 05_composition/main.go, add the following code between your String() method and the main() function:
// Runner can run
type Runner interface {
	Run() string
}

// Swimmer can swim
type Swimmer interface {
	Swim() string
}

// Triathlete combines Runner and Swimmer into one interface
type Triathlete interface {
	Runner
	Swimmer
}

// Athlete satisfies both Runner and Swimmer (and therefore Triathlete)
type Athlete struct {
	Name string
}

func (a Athlete) Run() string {
	return a.Name + " is running!"
}

func (a Athlete) Swim() string {
	return a.Name + " is swimming!"
}

What does this code do?

  • Runner and Swimmer are each single-method interfaces.
  • Triathlete embeds both, so any type that satisfies Triathlete must have both Run() and Swim().
  • Athlete has both methods, so it satisfies Triathlete automatically. No implements keyword needed.

You will not see a change yet. The next chunk wires these types into a function and calls them from main().

  • Now add the doTriathlon function between your Swim() method and main():
// doTriathlon accepts any Triathlete and exercises both abilities
func doTriathlon(t Triathlete) {
	fmt.Println(t.Run())
	fmt.Println(t.Swim())
}
  • Update your main() function to create an Athlete and pass it to doTriathlon:
func main() {
	p := Player{Name: "Alice", Score: 42}
	fmt.Println(p)

	athlete := Athlete{Name: "Bob"}
	doTriathlon(&athlete)
}

Why pass &athlete instead of athlete?

In this case, both would work because Athlete uses value receivers. Passing a pointer is a common Go convention for struct arguments, and it keeps the pattern consistent with what you learned in Step 4 about pointer receivers.

  • Save 05_composition/main.go and run the program:
go run 05_composition/main.go

You should see three lines of output:

Alice [Score: 42]
Bob is running!
Bob is swimming!

The Athlete satisfied Triathlete without declaring it. Go verified at compile time that Athlete has both Run() and Swim().

Seeing a compile error about missing methods?

Check that your Run() and Swim() method signatures return string and match the interface definitions exactly. The method name, parameters, and return type must all match.

help me fix my interface composition error

Add a compile-time interface check

Go programmers use a one-line idiom to catch interface drift at build time. If someone later removes a method from Athlete, this line makes the compiler reject the code immediately instead of failing silently at runtime.

  • In 05_composition/main.go, find your Swim() method.
  • Add this line directly below it, before the doTriathlon function:
var _ Triathlete = (*Athlete)(nil)

What does this line do?

This assigns nil (cast as a *Athlete pointer) to a blank variable of type Triathlete. At runtime it does nothing. At compile time, it forces Go to verify that *Athlete satisfies the Triathlete interface.

If you ever accidentally remove Swim() from Athlete, the compiler will refuse to build your program. You catch the bug before it reaches production.

  • Save 05_composition/main.go and run once more to confirm everything still compiles:
go run 05_composition/main.go

The output is identical to before. The compile-time check is invisible at runtime but protects you from future mistakes.

✔️ Awesome, I've got everything!

Great. Double check you have saved 05_composition/main.go.

ⓧ I'd like to double check the full code

Here is the complete 05_composition/main.go file:

package main

import "fmt"

type Player struct {
	Name  string
	Score int
}

func (p Player) String() string {
	return fmt.Sprintf("%s [Score: %d]", p.Name, p.Score)
}

type Runner interface {
	Run() string
}

type Swimmer interface {
	Swim() string
}

type Triathlete interface {
	Runner
	Swimmer
}

type Athlete struct {
	Name string
}

func (a Athlete) Run() string {
	return a.Name + " is running!"
}

func (a Athlete) Swim() string {
	return a.Name + " is swimming!"
}

var _ Triathlete = (*Athlete)(nil)

func doTriathlon(t Triathlete) {
	fmt.Println(t.Run())
	fmt.Println(t.Swim())
}

func main() {
	p := Player{Name: "Alice", Score: 42}
	fmt.Println(p)

	athlete := Athlete{Name: "Bob"}
	doTriathlon(&athlete)
}

You now have a working example of interface composition and a standard-library interface in action. Next up, the Secret Mission challenges you to handle unknown types with type switches and the empty interface.

Secret mission

Type Switches and the Empty Interface

You have mastered defined interfaces. But what happens when a function needs to accept literally anything? Build a mini shape calculator that uses Go's empty interface and type switches to handle unknown types gracefully.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your files for reference or delete them.

Resources you used:

  • The go-interfaces-lab folder on your Desktop (contains all 5 program directories and go.mod).

Keep everything running

No action needed. Choose this if you want to keep your interface examples as a reference for future Go projects.

  • Your go-interfaces-lab folder stays on your Desktop.
  • You can re-run any example at any time with go run from inside the project directory.

Pause - I'll come back to this later

No running processes to shut down. Your files sit on disk without consuming any resources.

  • Close the VS Code window for this project.
  • Your files remain on your Desktop whenever you want to return.

Delete - I don't want to use this again

Remove the entire project folder to start fresh.

  • Close the VS Code window for this project.
  • Open a new terminal and delete the project folder by running the command for your operating system.

macOS

rm -rf ~/Desktop/go-interfaces-lab

Windows

rmdir /s /q %USERPROFILE%\Desktop\go-interfaces-lab
  • Confirm the folder is gone by checking your Desktop.

That's a Wrap!

That's a Wrap!

Nice work! You just built five runnable Go programs that take you from the pain of repetitive code all the way to interface composition and standard library integration.

You've learned how to:

  • Define a Go interface and watch types satisfy it implicitly, collapsing duplicate functions into one polymorphic function.
  • Add a new type (Bird) without changing any existing code, experiencing polymorphism firsthand.
  • Navigate value vs pointer receivers, implement fmt.Stringer, and compose small interfaces into larger ones mirroring real standard library patterns.
  • Secret Mission: Used type switches and safe type assertions to handle the empty interface (any) gracefully.

Ready to quiz yourself?