Go Interfaces Deep Dive — For TypeScript Developers

Go Interfaces Deep Dive — For TypeScript Developers

3 months ago31 min read
#go#typescript#interfaces#programming

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

  1. Mental Model: What is an interface, really?
  2. Implicit vs Explicit Implementation
  3. Structural Typing — methods only, no fields
  4. Interface Value — something TS doesn't have
  5. Pointer vs Value Receiver — critical
  6. Nil Interface Trap
  7. Type Assertion
  8. Type Switch
  9. Interface Composition
  10. Idiomatic Usage — Consumer-side Interface
  11. Dependency Injection — no framework needed
  12. Real-world: io.Reader and the standard library
  13. Real-world: Production Code (partners-api)
  14. Empty Interface and any
  15. Performance Implications
  16. Common Mistakes & Anti-patterns
  17. Testing & Mocking with Interfaces
  18. Practical Roadmap
  19. 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 — typeof knows 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

AspectTypeScriptGo
EssenceShape of objectBehavior of value
RuntimeDoes not existExists (boxed value)
Contains fields?YesNo
Contains methods?YesYes (methods only)
ImplementationExplicit (implements)Implicit (auto)
Generics supportYesYes (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

TypeScriptGo
Interface describes"What an object looks like""What an object can do"
FocusData shapeBehavior
CouplingTo data structureTo 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

TypeScriptGo
Variable holdsJust the value(type, value) pair
Runtime type infotypeof (limited)Full reflection
OverheadNone16 bytes + possible allocation
Type inspectionNot precise100% 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 receiverValue (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 nil directly 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 timingCompile-time onlyRuntime
Wrong typeNo errorPanic (unsafe) or ok=false (safe)
SafetyDeveloper responsibilityLanguage enforced
PerformanceZero costSmall 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

TypeScriptGo
Mechanismtypeof, instanceof, type guardsswitch v := i.(type)
ScopeCompile-time narrowingRuntime dispatch
Custom typesNeeds discriminated unionsAutomatic with interfaces
ExhaustivenessVia never checkUse 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

TypeScriptGo
Syntax& or extendsEmbedding
Mindset"Combine shapes""Combine behaviors"
Standard libRarely uses this patternCore design principle
Interface sizeUsually largeEncouraged 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 / JavaGo
Where is the interface?Shared layer / producerConsumer
Who defines it?Architect / producerEach consumer defines its own
SizeUsually large (full contract)Small (1-3 methods)
PurposeTyping / contractDecoupling / DI
CouplingConsumer → shared → producerNo 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

  1. Implicit implementation → no registration required
  2. Constructor functions → no reflection needed
  3. Explicit wiring → easy to debug, easy to trace dependencies
  4. Compile-time safety → wrong type → compiler error immediately

DI comparison

TypeScript (NestJS)Go
MechanismContainer + DecoratorConstructor function
Registration@Injectable(), @Inject()Not needed
ResolutionRuntime (container)Compile-time
DebuggingHard (magic)Easy (explicit)
FrameworkRequired (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

InterfaceMethodsUsed for
io.ReaderRead([]byte) (int, error)Reading data
io.WriterWrite([]byte) (int, error)Writing data
io.CloserClose() errorReleasing resources
fmt.StringerString() stringString representation
errorError() stringError handling
sort.InterfaceLen(), Less(), Swap()Sorting
http.HandlerServeHTTP(w, r)HTTP handling
json.MarshalerMarshalJSON() ([]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

  1. Interface defined at consumer — the usecase interface lives in handler.go, not in usecase.go
  2. Unexported interfaceusecase (lowercase) → only used within the package
  3. Small interfaces — each interface has 2-3 methods
  4. Constructor injectionNewKYCEndpoint(usecase usecase)
  5. 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
Tooljest.mock(), jest.spyOn()Manual mock struct
SetupFramework magicExplicit code
Type safetyPartial (as unknown as T)Full compile-time
FlexibilityHigh (any value)High (custom behavior)
DebuggingStack traces through frameworkDirect — 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

ConceptRemember
What is an interface?Method set — a set of behaviors, not a shape
ImplementationImplicit — the compiler infers it, no implements needed
Interface value(type, value) pair — 16 bytes, exists at runtime
Pointer vs ValuePointer receiver → only pointer implements the interface
Nil trapinterface == nil only when BOTH type AND value are nil
Type assertionv, ok := i.(Type) — always use comma-ok
Type switchswitch v := i.(type) — runtime dispatch
CompositionEmbed interfaces — keep them small (1-3 methods)
DesignInterface belongs to the consumer, not the producer
Return typeAccept interfaces, return structs
DIConstructor injection — no framework needed
TestingManual 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

© 2026 Lộc Lê