
Go Interfaces Deep Dive — For TypeScript Developers
Go Interfaces Deep Dive — For TypeScript Developers
A comprehensive guide to understanding Go interfaces through the lens of TypeScript. Includes real-world examples from production Go codebases.
Table of Contents
- Mental Model: What is an interface, really?
- Implicit vs Explicit Implementation
- Structural Typing — methods only, no fields
- Interface Value — something TS doesn't have
- Pointer vs Value Receiver — critical
- Nil Interface Trap
- Type Assertion
- Type Switch
- Interface Composition
- Idiomatic Usage — Consumer-side Interface
- Dependency Injection — no framework needed
- Real-world: io.Reader and the standard library
- Real-world: Production Code (partners-api)
- Empty Interface and any
- Performance Implications
- Common Mistakes & Anti-patterns
- Testing & Mocking with Interfaces
- Practical Roadmap
- TL;DR Cheat Sheet
1. Mental Model
TypeScript: Interface = Shape (structure)
interface Animal {
name: string;
speak(): void;
}
- Purely a type-level contract — completely erased after compilation
- Describes the shape of an object: what fields and methods it has
- Does not exist at runtime —
typeofknows nothing about interfaces - Primarily used for type checking
Go: Interface = Behavior
type Animal interface {
Speak()
}
- A runtime value abstraction — actually exists at runtime
- Describes the method set (set of behaviors) that a type must have
- Cannot contain fields — only method signatures
- Used for: polymorphism, dependency injection, decoupling
Direct comparison
| Aspect | TypeScript | Go |
|---|---|---|
| Essence | Shape of object | Behavior of value |
| Runtime | Does not exist | Exists (boxed value) |
| Contains fields? | Yes | No |
| Contains methods? | Yes | Yes (methods only) |
| Implementation | Explicit (implements) | Implicit (auto) |
| Generics support | Yes | Yes (since Go 1.18) |
Why does this difference matter?
TypeScript interfaces serve developer experience — they help IDEs autocomplete, catch errors at compile time.
Go interfaces serve architecture — they are the primary mechanism for creating decoupled, testable code without class hierarchies.
2. Implicit vs Explicit Implementation
TypeScript: Explicit — must declare implements
interface Animal {
speak(): void;
}
class Dog implements Animal {
speak() {
console.log("woof");
}
}
// If you forget implements → compiler doesn't enforce
class Cat {
speak() {
console.log("meow");
}
}
// Cat has speak() but doesn't "implement Animal"
// → However, TS structural typing still allows:
const a: Animal = new Cat(); // ✅ still works due to structural typing
Go: Implicit — the compiler infers it
type Animal interface {
Speak()
}
type Dog struct{}
func (d Dog) Speak() {
fmt.Println("woof")
}
var a Animal = Dog{} // ✅ auto-implements — no declaration needed
The Go compiler automatically checks: "Does Dog have a Speak() method?" → Yes → Dog implements Animal.
Benefits of implicit implementation
1. Reduced coupling
// Package A defines the interface
package handler
type UserGetter interface {
GetUser(id string) (*User, error)
}
// Package B implements — NO NEED to import package A
package postgres
type UserRepo struct {
db *sql.DB
}
func (r *UserRepo) GetUser(id string) (*User, error) {
// query database
}
Package B doesn't need to know package A exists. There is no reverse import dependency.
2. Easy refactoring
Add a new interface without modifying existing code:
// Add a new interface
type UserDeleter interface {
DeleteUser(id string) error
}
// UserRepo already has a DeleteUser method → automatically implements
// No need to modify UserRepo code
3. Retroactive implementation
You can create interfaces for third-party types:
// A third-party library has a type with a Read() method
// You create an interface that matches → it automatically works
type DataReader interface {
Read(p []byte) (n int, err error)
}
// *os.File, *bytes.Buffer, *strings.Reader... all automatically implement it
Compile-time verification trick
If you want to ensure a type implements an interface (compile-time check):
// Compile-time assertion — zero cost at runtime
var _ Animal = Dog{} // value
var _ Animal = (*Dog)(nil) // pointer
If Dog doesn't implement Animal → compiler error immediately.
3. Structural Typing — methods only, no fields
TypeScript: Field-based structural typing
interface User {
name: string;
age: number;
}
// Any object with the right shape → matches
const u: User = { name: "Alice", age: 30 }; // ✅
// Even function returns match
function getUser(): User {
return { name: "Bob", age: 25 };
}
Go: Method-based structural typing
// Go does NOT allow fields in interfaces
type User interface {
name string // ❌ COMPILE ERROR
}
// Must use methods (getter pattern)
type User interface {
GetName() string
GetAge() int
}
type Person struct {
name string // unexported field
age int
}
func (p Person) GetName() string { return p.name }
func (p Person) GetAge() int { return p.age }
var u User = Person{name: "Alice", age: 30} // ✅
Why does Go do it this way?
Encapsulation: Fields are implementation details. Interfaces only care about behavior.
// Two structs with completely different internal state
// but both implement the same interface
type Person struct {
name string
}
type Robot struct {
serialNumber string
displayName string
}
func (p Person) GetName() string { return p.name }
func (r Robot) GetName() string { return r.displayName }
type Named interface {
GetName() string
}
// Both are Named — different implementations
func Greet(n Named) {
fmt.Printf("Hello, %s!\n", n.GetName())
}
Key insight
| TypeScript | Go | |
|---|---|---|
| Interface describes | "What an object looks like" | "What an object can do" |
| Focus | Data shape | Behavior |
| Coupling | To data structure | To capability |
4. Interface Value — something TS doesn't have
Go interface value = (type, value) pair
When you assign a concrete value to an interface variable, Go creates a boxed value consisting of 2 parts:
┌─────────────────────────┐
│ Interface Variable │
├────────────┬────────────┤
│ type │ value │
│ (*int) │ → 42 │
└────────────┴────────────┘
var i interface{} = 42
// i = (type: int, value: 42)
var s interface{} = "hello"
// s = (type: string, value: "hello")
var d interface{} = Dog{name: "Rex"}
// d = (type: Dog, value: Dog{name: "Rex"})
TypeScript "equivalent" (but fundamentally different)
let i: any = 42;
// i is just a variable holding a value
// no type metadata attached
Why does this matter?
1. Reflection works
var i interface{} = 42
fmt.Printf("Type: %T, Value: %v\n", i, i)
// Output: Type: int, Value: 42
// Using the reflect package
t := reflect.TypeOf(i) // int
v := reflect.ValueOf(i) // 42
2. Type assertion works at runtime
var i interface{} = "hello"
s, ok := i.(string) // ok = true, s = "hello"
n, ok := i.(int) // ok = false, n = 0
3. Function parameter polymorphism
func Print(i interface{}) {
// Go passes BOTH type info + value
fmt.Printf("[%T] %v\n", i, i)
}
Print(42) // [int] 42
Print("hello") // [string] hello
Print(Dog{}) // [main.Dog] {}
Actual memory layout
Interface variable (16 bytes on 64-bit):
┌────────────────┬────────────────┐
│ type pointer │ data pointer │
│ (8 bytes) │ (8 bytes) │
└────────────────┴────────────────┘
│ │
▼ ▼
┌─────────┐ ┌──────────┐
│ *_type │ │ actual │
│ (itab) │ │ value │
└─────────┘ └──────────┘
- itab (interface table): contains type metadata + method pointers
- data pointer: points to actual value (may be inlined if ≤ pointer size)
Detailed comparison
| TypeScript | Go | |
|---|---|---|
| Variable holds | Just the value | (type, value) pair |
| Runtime type info | typeof (limited) | Full reflection |
| Overhead | None | 16 bytes + possible allocation |
| Type inspection | Not precise | 100% precise |
5. Pointer vs Value Receiver — Critical
This concept does not exist in TypeScript/JavaScript.
Two kinds of receivers in Go
type Dog struct {
name string
}
// Value receiver — receives a COPY of Dog
func (d Dog) Speak() string {
return "woof"
}
// Pointer receiver — receives a POINTER to Dog
func (d *Dog) SetName(name string) {
d.name = name // modifies the original
}
Impact on interface implementation
type Speaker interface {
Speak() string
}
type Namer interface {
SetName(name string)
}
Case 1: Value receiver → both value and pointer implement
func (d Dog) Speak() string { return "woof" }
var s1 Speaker = Dog{} // ✅ value → OK
var s2 Speaker = &Dog{} // ✅ pointer → OK (Go auto-derefs)
Case 2: Pointer receiver → ONLY pointer implements
func (d *Dog) SetName(name string) { d.name = name }
var n1 Namer = Dog{} // ❌ COMPILE ERROR
var n2 Namer = &Dog{} // ✅ pointer → OK
Why does Go work this way?
// If value were allowed to implement a pointer receiver interface:
func (d *Dog) SetName(name string) {
d.name = name // wants to modify the original
}
var n Namer = Dog{name: "Rex"} // hypothetical ✅
n.SetName("Max")
// But n holds a COPY of Dog → SetName modifies the copy → original unchanged
// → SILENT BUG!
// Go prevents this at compile time
TypeScript: No such concept
class Dog {
name: string;
speak() {
return "woof";
}
setName(name: string) {
this.name = name; // always modifies the reference
}
}
const d = new Dog();
// JavaScript always passes objects by reference
// → no value vs pointer distinction
Summary rules
| Method receiver | Value (Dog{}) | Pointer (&Dog{}) |
|---|---|---|
func (d Dog) M() | ✅ implements | ✅ implements |
func (d *Dog) M() | ❌ does NOT implement | ✅ implements |
Guidelines for choosing a receiver
// Use POINTER receiver when:
// 1. The method needs to modify state
func (d *Dog) SetName(name string) { d.name = name }
// 2. The struct is large (avoid copy overhead)
func (d *BigStruct) Process() { /* ... */ }
// 3. Consistency — if one method uses pointer, all should use pointer
// Use VALUE receiver when:
// 1. Read-only operation on a small struct
func (p Point) Distance(other Point) float64 { /* ... */ }
// 2. Immutability guarantee
func (m Money) Add(other Money) Money { return Money{m.amount + other.amount} }
6. Nil Interface Trap
This is the most common trap when working with Go interfaces.
Case 1: Nil interface → works as expected
var i interface{} = nil
fmt.Println(i == nil) // true ✅
// Memory:
// i = (type: nil, value: nil)
Case 2: Interface holding a typed nil → TRAP!
var p *int = nil // p is a nil pointer
var i interface{} = p // assign to interface
fmt.Println(p == nil) // true ✅
fmt.Println(i == nil) // false ❗❗❗
Why?
p = nil
// Memory: just a nil pointer
i = p
// Memory: i = (type: *int, value: nil)
// type ≠ nil → i ≠ nil!
Visualization
Nil interface:
┌────────┬────────┐
│ nil │ nil │ ← both nil → i == nil ✅
└────────┴────────┘
Interface holding typed nil:
┌────────┬────────┐
│ *int │ nil │ ← type ≠ nil → i != nil ❌
└────────┴────────┘
Real-world bug scenario
type MyError struct {
msg string
}
func (e *MyError) Error() string { return e.msg }
func doSomething() error {
var err *MyError = nil
// ... logic ...
// err is still nil
return err // ⚠️ BUG! returns typed nil, not a nil interface
}
func main() {
err := doSomething()
if err != nil {
// THIS WILL EXECUTE! even though there's no real error
fmt.Println("error:", err) // error: <nil>
}
}
The correct fix
func doSomething() error {
var err *MyError = nil
// ... logic ...
if err != nil {
return err // only return when there's an actual error
}
return nil // return untyped nil
}
TypeScript: No such trap
let x: any = null;
console.log(x === null); // true — always correct
// JS/TS has no concept of "typed null"
// null is null, no type metadata attached
Rule to remember
Interface == nil ⟺ type == nil AND value == nil
Checklist:
- Always return
nildirectly for interface return types, never return a typed nil variable - Check the concrete type before assigning to an interface
- Use
reflect.ValueOf(i).IsNil()if you need to check for typed nil (but avoid it if possible)
7. Type Assertion
Go: Runtime type checking
var i interface{} = "hello"
// Unsafe assertion — panics if wrong type
s := i.(string)
fmt.Println(s) // "hello"
n := i.(int) // PANIC: interface conversion: interface {} is string, not int
// Safe assertion — use the comma-ok pattern
s, ok := i.(string)
if ok {
fmt.Println("It's a string:", s)
}
n, ok := i.(int)
if !ok {
fmt.Println("Not an int") // → this line runs
}
TypeScript: Compile-time type casting
const i: any = "hello";
// Type assertion — does NOT check at runtime
const s = i as string; // ✅ always works
const n = i as number; // ✅ also works — NO runtime error!
console.log(n); // "hello" — wrong type but no error
// Type guard — manual runtime check
if (typeof i === "string") {
console.log(i.toUpperCase()); // safe
}
Detailed comparison
TypeScript (as) | Go (.()) | |
|---|---|---|
| Check timing | Compile-time only | Runtime |
| Wrong type | No error | Panic (unsafe) or ok=false (safe) |
| Safety | Developer responsibility | Language enforced |
| Performance | Zero cost | Small runtime cost |
Interface-to-interface assertion
type Reader interface {
Read(p []byte) (n int, err error)
}
type ReadCloser interface {
Read(p []byte) (n int, err error)
Close() error
}
var r Reader = getReader()
// Assert that this Reader is also a ReadCloser
if rc, ok := r.(ReadCloser); ok {
defer rc.Close()
}
Best practices
// ❌ Don't do this — unsafe
value := i.(string)
// ✅ Always use comma-ok
value, ok := i.(string)
if !ok {
// handle gracefully
}
// ✅ Or use a type switch (next section)
8. Type Switch
Go: Pattern matching on types
func describe(i interface{}) string {
switch v := i.(type) {
case string:
return fmt.Sprintf("String of length %d: %s", len(v), v)
case int:
return fmt.Sprintf("Integer: %d", v)
case bool:
return fmt.Sprintf("Boolean: %t", v)
case nil:
return "Nil value"
default:
return fmt.Sprintf("Unknown type: %T", v)
}
}
describe("hello") // "String of length 5: hello"
describe(42) // "Integer: 42"
describe(true) // "Boolean: true"
describe(nil) // "Nil value"
TypeScript: Type narrowing
function describe(i: unknown): string {
if (typeof i === "string") {
return `String of length ${i.length}: ${i}`;
} else if (typeof i === "number") {
return `Number: ${i}`;
} else if (typeof i === "boolean") {
return `Boolean: ${i}`;
} else if (i === null || i === undefined) {
return "Null/undefined";
}
return `Unknown: ${i}`;
}
Interface-based type switch
type Shape interface {
Area() float64
}
type Circle struct{ Radius float64 }
type Rectangle struct{ Width, Height float64 }
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
func (r Rectangle) Area() float64 { return r.Width * r.Height }
func describeShape(s Shape) {
switch v := s.(type) {
case Circle:
fmt.Printf("Circle with radius %.1f, area = %.2f\n", v.Radius, v.Area())
case Rectangle:
fmt.Printf("Rectangle %.1f x %.1f, area = %.2f\n", v.Width, v.Height, v.Area())
}
}
Comparison
| TypeScript | Go | |
|---|---|---|
| Mechanism | typeof, instanceof, type guards | switch v := i.(type) |
| Scope | Compile-time narrowing | Runtime dispatch |
| Custom types | Needs discriminated unions | Automatic with interfaces |
| Exhaustiveness | Via never check | Use default case |
Multi-type case
switch v := i.(type) {
case int, int8, int16, int32, int64:
fmt.Println("Some kind of integer")
case string, []byte:
fmt.Println("String-like")
default:
fmt.Printf("Type: %T\n", v)
}
9. Interface Composition
Go: Embedding interfaces
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
// Composition — combine multiple interfaces
type ReadWriter interface {
Reader
Writer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}
TypeScript: Intersection types
interface Reader {
read(p: Uint8Array): number;
}
interface Writer {
write(p: Uint8Array): number;
}
type ReadWriter = Reader & Writer;
// Or extends
interface ReadWriter extends Reader, Writer {}
Go standard library examples
// io package — textbook interface composition
type ReadCloser interface {
Reader
Closer
}
type WriteCloser interface {
Writer
Closer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}
type ReadSeeker interface {
Reader
Seeker
}
// All built on small 1-2 method interfaces
Principle: Keep interfaces small
// ❌ God interface — too many methods
type UserService interface {
GetUser(id string) (*User, error)
CreateUser(u *User) error
UpdateUser(u *User) error
DeleteUser(id string) error
ListUsers() ([]*User, error)
SearchUsers(q string) ([]*User, error)
ValidateUser(u *User) error
NotifyUser(id string, msg string) error
}
// ✅ Small, focused interfaces
type UserGetter interface {
GetUser(id string) (*User, error)
}
type UserCreator interface {
CreateUser(u *User) error
}
type UserUpdater interface {
UpdateUser(u *User) error
}
// Compose when needed
type UserReadWriter interface {
UserGetter
UserCreator
UserUpdater
}
Comparison
| TypeScript | Go | |
|---|---|---|
| Syntax | & or extends | Embedding |
| Mindset | "Combine shapes" | "Combine behaviors" |
| Standard lib | Rarely uses this pattern | Core design principle |
| Interface size | Usually large | Encouraged small (1-3 methods) |
10. Idiomatic Usage — Consumer-side Interface
This is the biggest design philosophy difference between Go and TS/OOP.
TS/OOP mindset (what you're used to):
shared/interfaces/
├── IUserService.ts
├── IOrderService.ts
└── IPaymentService.ts
services/
├── UserService.ts → implements IUserService
├── OrderService.ts → implements IOrderService
└── PaymentService.ts → implements IPaymentService
The interface lives in the shared layer — both producer and consumer import it.
Go mindset (what you need to learn):
// ❌ WRONG — TS/Java style
// file: service/interfaces.go
type UserService interface {
GetUser(id string) (*User, error)
CreateUser(u *User) error
UpdateUser(u *User) error
DeleteUser(id string) error
}
// file: service/user_service.go
type userService struct { db *sql.DB }
func (s *userService) GetUser(id string) (*User, error) { /* ... */ }
func (s *userService) CreateUser(u *User) error { /* ... */ }
// ...
// ✅ CORRECT — idiomatic Go
// Producer: exports concrete type + methods
// file: service/user_service.go
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(id string) (*User, error) { /* ... */ }
func (s *UserService) CreateUser(u *User) error { /* ... */ }
func (s *UserService) UpdateUser(u *User) error { /* ... */ }
func (s *UserService) DeleteUser(id string) error { /* ... */ }
// Consumer: defines interface with ONLY the methods it needs
// file: handler/user_handler.go
type userGetter interface {
GetUser(id string) (*User, error)
}
type UserHandler struct {
users userGetter // only needs GetUser
}
Why consumer-side?
1. Natural Interface Segregation
Each consumer only declares the methods it needs → automatic ISP (Interface Segregation Principle).
// Handler only needs to read
type userReader interface {
GetUser(id string) (*User, error)
}
// Admin handler needs read + delete
type userAdmin interface {
GetUser(id string) (*User, error)
DeleteUser(id string) error
}
// The same UserService struct implements both
// without UserService knowing about these interfaces
2. Easy to test
// Mock only needs to implement the methods the consumer actually uses
type mockUserGetter struct{}
func (m *mockUserGetter) GetUser(id string) (*User, error) {
return &User{Name: "test"}, nil
}
// No need to mock 10 methods of UserService
3. True decoupling
// Consumer doesn't import the producer package
// Producer doesn't know the consumer exists
// → Zero coupling
Philosophy comparison
| TypeScript / Java | Go | |
|---|---|---|
| Where is the interface? | Shared layer / producer | Consumer |
| Who defines it? | Architect / producer | Each consumer defines its own |
| Size | Usually large (full contract) | Small (1-3 methods) |
| Purpose | Typing / contract | Decoupling / DI |
| Coupling | Consumer → shared → producer | No coupling |
Go proverb
"The bigger the interface, the weaker the abstraction." — Rob Pike
11. Dependency Injection — no framework needed
TypeScript: Usually requires a DI framework
// NestJS style
@Injectable()
class UserService {
constructor(
@Inject('USER_REPO') private repo: UserRepository,
@Inject('LOGGER') private logger: Logger,
) {}
}
// Requires decorators, containers, module registration...
Go: Pure constructor injection
// Interface (consumer-side)
type userRepo interface {
GetUser(ctx context.Context, id string) (*User, error)
SaveUser(ctx context.Context, u *User) error
}
// Service struct
type UserService struct {
repo userRepo
log *zerolog.Logger
}
// Constructor — DI via function parameters
func NewUserService(repo userRepo, log *zerolog.Logger) *UserService {
return &UserService{
repo: repo,
log: log,
}
}
// Wiring in main.go or a wire function
func main() {
db := connectDB()
repo := postgres.NewUserRepo(db)
logger := zerolog.New(os.Stdout)
service := NewUserService(repo, &logger) // inject!
handler := NewHandler(service)
// ...
}
Why no framework is needed
- Implicit implementation → no registration required
- Constructor functions → no reflection needed
- Explicit wiring → easy to debug, easy to trace dependencies
- Compile-time safety → wrong type → compiler error immediately
DI comparison
| TypeScript (NestJS) | Go | |
|---|---|---|
| Mechanism | Container + Decorator | Constructor function |
| Registration | @Injectable(), @Inject() | Not needed |
| Resolution | Runtime (container) | Compile-time |
| Debugging | Hard (magic) | Easy (explicit) |
| Framework | Required (NestJS, tsyringe...) | Not needed |
12. Real-world: io.Reader and the Standard Library
io.Reader — the most important interface in Go
type Reader interface {
Read(p []byte) (n int, err error)
}
Just 1 method. But extremely powerful.
What implements io.Reader?
*os.File // read from file
*bytes.Buffer // read from buffer
*strings.Reader // read from string
*net.Conn // read from network
*http.Request.Body // read from HTTP request body
*gzip.Reader // read gzip compressed data
*csv.Reader // read CSV
*json.Decoder // read JSON stream
// ... hundreds of implementations
The power of abstraction
// This function works with ANY Reader
func CountBytes(r io.Reader) (int64, error) {
var total int64
buf := make([]byte, 1024)
for {
n, err := r.Read(buf)
total += int64(n)
if err == io.EOF {
return total, nil
}
if err != nil {
return total, err
}
}
}
// Use with a file
f, _ := os.Open("data.txt")
count, _ := CountBytes(f)
// Use with a string
s := strings.NewReader("hello world")
count, _ := CountBytes(s)
// Use with an HTTP response
resp, _ := http.Get("https://example.com")
count, _ := CountBytes(resp.Body)
// Use with gzip
gz, _ := gzip.NewReader(f)
count, _ := CountBytes(gz)
Composition with io interfaces
// Natural decorator pattern
func LogReader(r io.Reader, label string) io.Reader {
return &loggingReader{r: r, label: label}
}
type loggingReader struct {
r io.Reader
label string
}
func (lr *loggingReader) Read(p []byte) (n int, err error) {
n, err = lr.r.Read(p)
log.Printf("[%s] Read %d bytes, err=%v", lr.label, n, err)
return
}
// Chain decorators
r := LogReader(gzip.NewReader(encryptedFile), "decrypt-decompress")
TypeScript equivalent (more limited)
interface Reader {
read(): string;
}
// TS doesn't have a standard lib as powerful as Go's
// Each library defines its own interface
// No "universal Reader" concept
// Node.js has Readable streams but they're much more complex
Key Go standard library interfaces
| Interface | Methods | Used for |
|---|---|---|
io.Reader | Read([]byte) (int, error) | Reading data |
io.Writer | Write([]byte) (int, error) | Writing data |
io.Closer | Close() error | Releasing resources |
fmt.Stringer | String() string | String representation |
error | Error() string | Error handling |
sort.Interface | Len(), Less(), Swap() | Sorting |
http.Handler | ServeHTTP(w, r) | HTTP handling |
json.Marshaler | MarshalJSON() ([]byte, error) | Custom JSON |
13. Real-world: Production Code (partners-api)
Real examples from a production codebase.
Pattern: Handler → Usecase → Repository
Layer 1: Handler (consumer) — defines interface for the usecase
// handler.go
type usecase interface {
generateAccessToken(ctx context.Context, userID string, email string) (*KYCSessionResponse, error)
getKYCApplicant(ctx context.Context, userID string) (*KYCApplicantResponse, error)
CheckIsUnApprovedIB(ctx context.Context, scaUserID string) error
}
type KYCEndpoint struct {
usecase usecase // depends on interface, not concrete type
}
func NewKYCEndpoint(usecase usecase) *KYCEndpoint {
return &KYCEndpoint{usecase: usecase}
}
Layer 2: Usecase (consumer of repo, producer for handler) — defines interfaces for its dependencies
// usecase.go
type client interface {
generateAccessToken(ctx context.Context, userID string) (*KYCSessionResponse, error)
createApplicant(ctx context.Context, userID string, email string) (*SumsubApplicant, error)
}
type kycApplicantClient interface {
getKYCApplicantByUserID(ctx context.Context, userID string) (*KYCApplicant, error)
createKYCApplicant(ctx context.Context, userID string, applicantID string) (*KYCApplicant, error)
GetUnApprovedIBBySCAUserID(ctx context.Context, scaUserID string) error
}
type kycUsecase struct {
client client // external API dependency
repo kycApplicantClient // database dependency
log *zerolog.Logger
}
Layer 3: Repository (producer) — concrete implementation
// repository.go
type RegistryDBRepository struct {
db *sqlx.DB
}
func NewRepository(db *sqlx.DB) *RegistryDBRepository {
return &RegistryDBRepository{db: db}
}
// These methods IMPLICITLY implement the kycApplicantClient interface
func (r *RegistryDBRepository) getKYCApplicantByUserID(ctx context.Context, userID string) (*KYCApplicant, error) {
// SQL query...
}
func (r *RegistryDBRepository) createKYCApplicant(ctx context.Context, userID string, applicantID string) (*KYCApplicant, error) {
// SQL insert...
}
func (r *RegistryDBRepository) GetUnApprovedIBBySCAUserID(ctx context.Context, scaUserID string) error {
// SQL query...
}
Wiring — where everything connects
// routes.go
func RegisterRoutes(api huma.API, cfg config.Config, partnersDB *sqlx.DB, kycClient *KYCClient, log *zerolog.Logger) {
// Create concrete implementations
repo := NewRepository(partnersDB)
u := &kycUsecase{
client: kycClient,
log: log,
repo: repo, // concrete → interface (implicit)
}
endpoint := NewKYCEndpoint(u) // concrete → interface (implicit)
// Register HTTP routes
huma.Register(api, config.WithBearerAuth(huma.Operation{
Method: http.MethodPost,
Path: "/v1/kyc/session",
}), endpoint.GetAccessTokenHandler)
}
Dependency flow visualization
┌─────────────────────────────────────────────────────────────────────┐
│ RegisterRoutes() │
│ (wiring layer — knows all concrete types) │
└──────────┬────────────────────┬───────────────────────┬─────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌────────────────┐ ┌───────────────────┐
│ KYCEndpoint │ │ kycUsecase │ │ RegistryDBRepo │
│ (handler) │ │ (usecase) │ │ (repository) │
├──────────────┤ ├────────────────┤ ├───────────────────┤
│ needs: │ │ needs: │ │ provides: │
│ usecase │──▶│ client │ │ getKYCApplicant │
│ interface │ │ repo │──▶│ createKYCApplicant│
└──────────────┘ │ interface │ │ GetUnApproved... │
└────────────────┘ └───────────────────┘
Consumer defines Consumer defines Producer just has
its own interface its own interfaces methods — doesn't
know about interfaces
Key observations from production code
- Interface defined at consumer — the
usecaseinterface lives inhandler.go, not inusecase.go - Unexported interface —
usecase(lowercase) → only used within the package - Small interfaces — each interface has 2-3 methods
- Constructor injection —
NewKYCEndpoint(usecase usecase) - No import cycles — handler doesn't directly import the usecase package
14. Empty Interface and any
interface{} = accepts any type
// Before Go 1.18
func Print(v interface{}) {
fmt.Println(v)
}
// Since Go 1.18 — alias
func Print(v any) { // any = interface{}
fmt.Println(v)
}
TypeScript equivalent
function print(v: any): void {
console.log(v);
}
// Or unknown (safer)
function print(v: unknown): void {
console.log(v);
}
When to use any / interface{}
// ✅ JSON decoding (shape not known in advance)
var data map[string]any
json.Unmarshal(body, &data)
// ✅ Generic container (before Go 1.18)
type Stack struct {
items []interface{}
}
// ❌ Being lazy about defining an interface
func Process(thing interface{}) { // BAD — too generic
// needs type assertions everywhere
}
// ✅ With Go 1.18 generics — better
func Process[T any](thing T) {
// type-safe
}
any vs Generics (Go 1.18+)
// Before generics — using interface{}
func Contains(slice []interface{}, item interface{}) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
// With generics — type-safe + performant
func Contains[T comparable](slice []T, item T) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
15. Performance Implications
Interface call = indirect (virtual) dispatch
type Adder interface {
Add(a, b int) int
}
type SimpleAdder struct{}
func (s SimpleAdder) Add(a, b int) int { return a + b }
// Direct call — compiler can inline
s := SimpleAdder{}
result := s.Add(1, 2) // direct → fast, inlineable
// Interface call — must look up method via itab
var a Adder = SimpleAdder{}
result := a.Add(1, 2) // indirect → lookup overhead, not inlineable
Allocation overhead
// Value type → interface may cause heap allocation
func process(i interface{}) { /* ... */ }
x := 42
process(x) // x gets "boxed" → may allocate on heap
// Pointer → already on heap, no additional allocation
p := &MyStruct{}
process(p) // p is already a pointer, less overhead
Benchmark comparison
BenchmarkDirectCall-8 1000000000 0.25 ns/op 0 B/op 0 allocs/op
BenchmarkInterfaceCall-8 500000000 2.50 ns/op 0 B/op 0 allocs/op
BenchmarkInterfaceAlloc-8 200000000 8.00 ns/op 16 B/op 1 allocs/op
TypeScript: Completely different
// JS runtime is dynamic anyway
// Object property lookup is always a hash table lookup
// No concept of "direct vs indirect call"
// V8 JIT optimizes hot paths
const obj = { add: (a: number, b: number) => a + b };
obj.add(1, 2); // V8 inline cache → fast after warmup
When should you care about performance?
// ❌ DON'T optimize prematurely
// Interface overhead is ~2-8ns — negligible for most use cases
// ✅ Care about it when:
// 1. Hot loops processing millions of items/sec
// 2. Allocation-sensitive workloads (GC pressure)
// 3. Benchmarks prove it's a bottleneck
// Profile first, optimize later
16. Common Mistakes & Anti-patterns
Mistake 1: Too many interfaces (over-abstraction)
// ❌ Every struct gets a corresponding interface
type UserService interface {
GetUser(id string) (*User, error)
}
type userService struct{}
// Only 1 implementation → the interface is meaningless!
// ✅ Only create an interface when:
// - There are multiple implementations
// - You need mocking for tests
// - You need decoupling between packages
Mistake 2: Interface on the producer side
// ❌ TS/Java style — interface in the same package as implementation
package user
type Service interface {
GetUser(id string) (*User, error)
}
type service struct{}
func (s *service) GetUser(id string) (*User, error) { /* ... */ }
// ✅ Go style — interface at the consumer
package handler
type userGetter interface {
GetUser(id string) (*User, error)
}
Mistake 3: Returning interface instead of concrete type
// ❌ Returns interface
func NewUserService() UserService {
return &userService{}
}
// ✅ Returns concrete type
func NewUserService() *UserService {
return &UserService{}
}
Go proverb: "Accept interfaces, return structs."
Mistake 4: Typed nil return
// ❌ Returns typed nil
func GetError() error {
var err *MyError = nil
return err // interface holds (*MyError, nil) → != nil!
}
// ✅ Returns nil directly
func GetError() error {
return nil
}
Mistake 5: God interface
// ❌ Too many methods
type Repository interface {
GetUser(id string) (*User, error)
CreateUser(u *User) error
UpdateUser(u *User) error
DeleteUser(id string) error
GetOrder(id string) (*Order, error)
CreateOrder(o *Order) error
// ... 20 more methods
}
// ✅ Small and focused
type UserReader interface {
GetUser(id string) (*User, error)
}
17. Testing & Mocking with Interfaces
Creating a mock implementation
// Interface at the consumer
type userRepo interface {
GetUser(ctx context.Context, id string) (*User, error)
}
// Mock struct
type mockUserRepo struct {
user *User
err error
}
func (m *mockUserRepo) GetUser(ctx context.Context, id string) (*User, error) {
return m.user, m.err
}
// Test
func TestGetUserHandler_Success(t *testing.T) {
mock := &mockUserRepo{
user: &User{ID: "1", Name: "Alice"},
err: nil,
}
handler := NewHandler(mock)
result, err := handler.GetUser(context.Background(), "1")
assert.NoError(t, err)
assert.Equal(t, "Alice", result.Name)
}
func TestGetUserHandler_NotFound(t *testing.T) {
mock := &mockUserRepo{
user: nil,
err: ErrNotFound,
}
handler := NewHandler(mock)
_, err := handler.GetUser(context.Background(), "999")
assert.ErrorIs(t, err, ErrNotFound)
}
Table-driven tests with mocks
func TestGetUser(t *testing.T) {
tests := []struct {
name string
mock *mockUserRepo
wantErr bool
wantName string
}{
{
name: "success",
mock: &mockUserRepo{user: &User{Name: "Alice"}},
wantErr: false,
wantName: "Alice",
},
{
name: "not found",
mock: &mockUserRepo{err: ErrNotFound},
wantErr: true,
},
{
name: "db error",
mock: &mockUserRepo{err: errors.New("connection refused")},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewHandler(tt.mock)
result, err := handler.GetUser(context.Background(), "1")
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantName, result.Name)
})
}
}
Mocking approach comparison
| TypeScript (Jest) | Go | |
|---|---|---|
| Tool | jest.mock(), jest.spyOn() | Manual mock struct |
| Setup | Framework magic | Explicit code |
| Type safety | Partial (as unknown as T) | Full compile-time |
| Flexibility | High (any value) | High (custom behavior) |
| Debugging | Stack traces through framework | Direct — no framework layer |
18. Practical Roadmap
Phase 1: Foundation (Weeks 1-2)
□ Understand that interface = method set (not shape)
□ Write 5 examples of implicit implementation
□ Practice pointer vs value receiver
□ Encounter and debug the nil interface trap
□ Use compile-time assertion: var _ Interface = (*Struct)(nil)
Phase 2: Patterns (Weeks 3-4)
□ Implement the repository pattern with interfaces
□ Write an HTTP handler that depends on an interface
□ Create mocks and write unit tests
□ Practice consumer-side interface design
□ Use interface composition (embedding)
Phase 3: Advanced (Weeks 5-8)
□ Implement a middleware chain with interfaces
□ Use type switches for error handling
□ Build io.Reader/io.Writer pipelines
□ Design clean architecture with interface boundaries
□ Benchmark interface vs direct calls
Suggested exercises
Exercise 1: Logger interface
// Define
type Logger interface {
Info(msg string, fields ...Field)
Error(msg string, err error, fields ...Field)
}
// Implement: ConsoleLogger, FileLogger, NoopLogger
// Use it in a service
Exercise 2: Cache interface
type Cache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte, ttl time.Duration) error
Delete(key string) error
}
// Implement: MemoryCache, RedisCache
// Consumer only needs Get → define a smaller interface
Exercise 3: HTTP middleware
type Middleware func(http.Handler) http.Handler
// Implement: logging, auth, rateLimit, cors
// Chain them together
19. TL;DR Cheat Sheet
| Concept | Remember |
|---|---|
| What is an interface? | Method set — a set of behaviors, not a shape |
| Implementation | Implicit — the compiler infers it, no implements needed |
| Interface value | (type, value) pair — 16 bytes, exists at runtime |
| Pointer vs Value | Pointer receiver → only pointer implements the interface |
| Nil trap | interface == nil only when BOTH type AND value are nil |
| Type assertion | v, ok := i.(Type) — always use comma-ok |
| Type switch | switch v := i.(type) — runtime dispatch |
| Composition | Embed interfaces — keep them small (1-3 methods) |
| Design | Interface belongs to the consumer, not the producer |
| Return type | Accept interfaces, return structs |
| DI | Constructor injection — no framework needed |
| Testing | Manual mock struct — full compile-time safety |
| Performance | ~2-8ns overhead — negligible, don't prematurely optimize |
any | = interface{} — use when type is unknown |
Quick reference card
// Define interface (at the consumer)
type Doer interface {
Do(ctx context.Context) error
}
// Implement (implicit)
type MyDoer struct{}
func (d *MyDoer) Do(ctx context.Context) error { return nil }
// Compile-time check
var _ Doer = (*MyDoer)(nil)
// DI
func NewService(d Doer) *Service {
return &Service{doer: d}
}
// Type assertion (safe)
if concrete, ok := i.(MyDoer); ok { /* ... */ }
// Type switch
switch v := i.(type) {
case string: // ...
case int: // ...
}
// Composition
type ReadWriter interface {
Reader
Writer
}
Document version: 1.0 — March 2026 Target audience: TypeScript developers learning Go