Chapter 6: Directional Channels and API Design

Chapters 3 through 5 established channel mechanics: creation, send/receive semantics, closing, and buffering. You can build working concurrent programs with these primitives. But “working” isn’t the same as “correct by construction.” Directional channel types transform channels from data pipes into self-documenting API contracts—encoding ownership, intent, and safety guarantees in your type signatures.

Which Go are we on?

Go 1.25+, the same baseline as the rest of the book. It matters twice here. The compiler messages quoted in §6.1 and §6.2 are what go build prints today — the wording changed when go/types took over error reporting, so older articles quote a different form. And the note on time.Tick in §6.5 reflects the Go 1.23 timer changes that Chapter 4 §4.4 works through in detail.

What you'll learn
  • Send-only channels (chan<- T): syntax, permitted operations, and use cases
  • Receive-only channels (<-chan T): syntax, permitted operations, and use cases
  • Compile-time safety: what the type system prevents and why it matters
  • Ownership patterns: who creates, who sends, who receives, who closes
  • The output channel pattern: returning <-chan T from functions
  • The generator pattern: functions that produce value streams
  • API design guidelines: principles for concurrent function signatures
What we’re NOT covering in Chapter 6
  • Channel-based patterns (pipelines, fan-out/fan-in, worker pools)—Chapter 7
  • Context for cancellation—Chapter 13
  • Error handling across goroutine boundaries—Chapter 14

This chapter focuses on the type system and API design. Chapter 7 builds on these foundations to implement production patterns.

Prerequisites

You should understand channel lifecycle from Chapter 3 (especially the sender-closes principle from Section 3.3), buffered channel semantics from Chapter 5, and basic select usage from Chapter 4.

Consider this coordinator that spawns workers:

coordinator_bug.go
// Illustrative snippet — not a complete program
func coordinator(tasks []Task) []Result {
    ch := make(chan Result, len(tasks))

    for _, task := range tasks {
        go worker(task, ch)
    }

    var results []Result
    for range tasks {
        results = append(results, <-ch)
    }
    return results
}

func worker(task Task, ch chan Result) {  // Bidirectional channel
    result := process(task)
    ch <- result
    close(ch)  // BUG: Worker closes shared channel
}

Multiple workers close the same channel. The first worker closes successfully. When subsequent workers try to send or close, the program panics—either panic: send on closed channel or panic: close of closed channel, depending on timing.

The coordinator owns the channel and expects len(tasks) results. But nothing in the type signature tells the worker this. The worker receives chan Result—a bidirectional channel—and the compiler permits sending, receiving, and closing. The programmer’s intent—“workers should only send, not control lifecycle”—exists only in documentation.

This bug compiles. The type system sees chan Result and allows all operations.

BIDIRECTIONAL CHANNELS: UNSAFE API

A worker taking a plain bidirectional channel. Arrows fan out from the parameter to every operation the compiler will allow: send, receive, and close. Nothing in the signature says which of them the worker is supposed to do.

Directional channel types solve half of this problem:

worker_sendonly.go
// Illustrative snippet — not a complete program
func worker(task Task, results chan<- Result) {
    result := process(task)
    results <- result

    // close(results)  // ✓ Compiles—but don’t

    _ = <-results     // ✗ Compile error
}

The type chan<- Result declares: “this function sends results.” The compiler prevents the worker from receiving—a common bug where producers accidentally consume their own output. Closing is still technically permitted (closing is a sender operation), but the type documents intent: this function contributes values, it doesn’t control lifecycle.

DIRECTIONAL CHANNELS: SAFER API

The same worker taking a send-only channel. The receive arrow is struck out — the compiler rejects it — so the signature itself now says the worker produces and never consumes.

CRITICAL DISTINCTION: CAPABILITY VS. CONVENTION

The type chan<- Result prevents receiving—a common bug where producers accidentally consume their own output. But it does NOT prevent closing. Both chan T and chan<- T can call close(). The multiple-close bug in the opening example requires ownership patterns (Section 6.4), not just directional types. The type documents intent and prevents wrong-direction operations; convention and patterns ensure correct lifecycle management.

This chapter covers both:

  • Sections 6.1–6.3: What directional types enforce at compile time
  • Sections 6.4–6.6: Ownership patterns that complement type safety

6.1 Send-Only Channels: chan<- T

A send-only channel restricts a channel reference to sender operations. The holder can deposit values and signal completion, but cannot receive or observe the channel’s contents. This constraint, partially enforced at compile time and partially by convention, prevents an entire class of bugs.


Syntax

The arrow points into the channel—values flow in, nothing comes out:

sendonly_syntax.go
// Illustrative snippet — not a complete program
chan<- T    // Send-only: can send T values in
 // ^^^
 //  └── Arrow points INTO chan: "send into"

Memory aid: Read chan<- as “I send to channel” or “values flow from me into the channel.” The arrow shows the direction values flow from your code’s perspective.

SEND-ONLY CHANNEL VISUAL

Three columns: your code, the channel, and other code. An arrow runs from your code into the channel marked allowed; the arrow coming back out is blocked by the compiler. A send-only channel lets you push values in and never pull them out.


Permitted and Prohibited Operations

A send-only channel permits sender operations and prohibits receiver operations:

Send-Only Channel Operations
ch <- v (send)
✓ Permitted
close(ch)
✓ Permitted
<-ch (receive)
✗ Compile error
for v := range ch
✗ Compile error
len(ch), cap(ch)
✓ Permitted
sendonly_operations.go
// Illustrative snippet — not a complete program
// Won’t compile—demonstrates valid and invalid operations
func demonstrateSendOnly(ch chan<- int) {
    ch <- 42            // ✓ Send permitted
    close(ch)           // ✓ Close permitted

    _ = len(ch)         // ✓ Buffer inspection
    _ = cap(ch)         // ✓ Capacity inspection

    _ = <-ch            // ✗ Compile error
    for range ch {      // ✗ Compile error
    }
}

Compiler error for receive:

Output
./main.go:10:8: invalid operation: cannot receive from
    send-only channel chan<- int ch (variable of type chan<- int)
./main.go:11:12: cannot range over ch (variable of type
    chan<- int): receive from send-only channel chan<- int

What Send-Only Actually Prevents

The primary value of chan<- T is preventing accidental receives:

accidental_receive.go
// Illustrative snippet — not a complete program
// ✗ WITHOUT send-only: bug compiles
func worker(results chan Result, task Task) {
    result := process(task)
    results <- result

    // Oops—accidentally received!
    if other := <-results; other.Valid {
        // Worker consumes results
        // meant for the coordinator
    }
}

// ✓ WITH send-only: bug caught
func worker(results chan<- Result, task Task) {
    result := process(task)
    results <- result

    if other := <-results; other.Valid {
        // ✗ Compile error
    }
}

In concurrent systems, accidentally receiving from a channel you should only send to causes subtle bugs: stolen results, deadlocks, or corrupted data flow. The type system catches this at compile time.


Closing Behavior

Send-only channels can close—closing is a sender operation that signals “no more values will be sent.” The Go specification permits close() on bidirectional (chan T) and send-only (chan<- T) channels, but prohibits it on receive-only (<-chan T)—aligning with the sender-closes principle from Section 3.3. But should a given producer close? The answer depends on ownership.

Single Producer: Close When Done

When a function is the sole producer, it owns the channel’s lifecycle and should close:

single_producer.go
// Illustrative snippet — not a complete program
func generateSequence(out chan<- int, count int) {
    defer close(out)  // ✓ Single producer closes
    for i := 0; i < count; i++ {
        out <- i
    }
}

Multiple Producers: Coordinate Closing

When multiple goroutines share a channel, individual producers shouldn’t close—they don’t know when all senders are done:

multi_producer.go
// Illustrative snippet — not a complete program
func worker(id int, results chan<- int) {
    results <- id * 10
    // Don’t close here—other workers are still sending
    // close(results)  // Legal, but WRONG for this pattern
}

func main() {
    results := make(chan int)
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Go(func() {
            worker(i, results)
        })
    }

    // Coordinator closes after ALL finish
    go func() {
        wg.Wait()
        close(results)  // Owner closes
    }()

    for r := range results {
        fmt.Println(r)
    }
}

Conversion: Bidirectional to Send-Only

Go implicitly converts bidirectional channels to directional channels. This is safe because it only removes capabilities:

conversion.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)    // Bidirectional

    var sendOnly chan<- int = ch  // ✓ Implicit
    sendOnly <- 42                // ✓ Can send

    // Reverse is NOT allowed:
    var bidir chan int = sendOnly  // ✗ Compile error
}
CHANNEL TYPE CONVERSIONS

A conversion map. A bidirectional channel sits at the top with arrows down to send-only and receive-only, both labeled as permitted. Every arrow back upward, and every arrow across between the two directional types, is marked forbidden.

This enables the key pattern: create bidirectional, pass directional:

create_bidir_pass_dir.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)  // Full control here

    go sender(ch)      // → chan<- int (implicit)
    receiver(ch)        // → <-chan int (implicit)
}

func sender(ch chan<- int)   { ch <- 42; close(ch) }
func receiver(ch <-chan int) { fmt.Println(<-ch) }

The creator maintains full control; callees receive only the capabilities they need.

TYPE RESTRICTION IS PERMANENT

Once a channel is restricted, you cannot unrestrict it—even with type assertions:

type_restriction.go
// Illustrative snippet — not a complete program
ch := make(chan int)
sendOnly := (chan<- int)(ch)  // Restrict

// All attempts to convert back fail
_ = (chan int)(sendOnly)              // ✗ Compile error
_ = sendOnly.(chan int)               // ✗ Not an interface
_ = interface{}(sendOnly).(chan int)  // ✗ Runtime panic

This is intentional—if you could convert back, the type safety would be meaningless.


When to Use Send-Only Channels

CHANNEL DIRECTION ENCODES RESPONSIBILITY

The type tells you the function’s role in the channel’s lifecycle.

Send-Only Producer Roles
Row
Producer (shared channel)
Producer (owns channel)

Section 6.3 presents the complete matrix covering all channel types and roles, including consumers (<-chan T) and coordinators (chan T).


Common Mistakes

Mistake 1: Creating Directional Channels Directly

mistake1_make_sendonly.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Compiles, but useless—nothing can ever receive from this
sendOnly := make(chan<- int)  // Legal Go, but creates a dead channel

// ✓ CORRECT: Make bidirectional, convert when passing
ch := make(chan int)
go sender(ch)  // Implicit conversion to chan<- int

Go allows make on any channel type, including directional ones—this compiles without error. But the result is useless: you get a channel you can send to, but nothing can ever receive from it (the directional restriction is permanent). Always create bidirectional, then narrow via conversion.

Mistake 2: Closing in Multi-Producer Scenarios

mistake2_multi_close.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Individual producer closes shared channel
func worker(results chan<- int, id int) {
    results <- id
    close(results)  // Compiles, but causes panic
}

// ✓ CORRECT: Let coordinator close
func worker(results chan<- int, id int) {
    results <- id
    // Coordinator closes after all workers finish
}

Mistake 3: Returning Send-Only Channels

mistake3_return_sendonly.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Caller can't receive—useless return value
func generate() chan<- int {
    ch := make(chan int)
    go func() {
        ch <- 42
        close(ch)
    }()
    return ch  // Caller can only send to this!
}

// ✓ CORRECT: Return receive-only (Section 6.5)
func generate() <-chan int {
    ch := make(chan int)
    go func() {
        ch <- 42
        close(ch)
    }()
    return ch  // Caller receives; function retains send
}

Key Takeaways

  1. chan<- T prevents receives at compile time—the primary safety value, catching producer-consuming-own-output bugs
  2. Closing is permitted but governed by ownership—single producer closes; multiple producers let the coordinator close
  3. Create bidirectional, pass directionalchan Tchan<- T conversion is implicit and permanent
  4. make(chan<- T) is legal but useless—always create chan T and narrow at the call site
  5. The type signature documents intent—readers know the function’s role without reading the body

Next: Section 6.2 covers receive-only channels (<-chan T)—the complement to send-only channels, used for consumer functions that cannot send or close.

Section 6.1 — in one line

chan<- T says “this function produces.” The compiler will reject a receive or a range on it, but not a close—so the type buys you capability, and who closes is still a decision you have to make.


6.2 Receive-Only Channels: <-chan T

Section 6.1 showed how send-only channels prevent producers from receiving. The complement is receive-only channels—restricting a reference to consumer operations only.

Consider a coordinator that collects results:

collector_bug.go
// Illustrative snippet — not a complete program
func coordinator(tasks []Task) []Result {
    results := make(chan Result, len(tasks))

    for _, task := range tasks {
        go worker(task, results)
    }

    return collectResults(results, len(tasks))
}

func collectResults(
    ch chan Result,  // Bidirectional
    count int,
) []Result {
    var out []Result
    for i := 0; i < count; i++ {
        out = append(out, <-ch)
    }

    // Bug: consumer shouldn't close
    close(ch)  // Violates sender-closes

    return out
}

The collectResults function should only consume results. But chan Result permits anything—including closing. Here the buffer happens to absorb all sends before close, but the pattern is fragile—reduce the buffer, change the timing, or add more workers, and you get panic: send on closed channel.

This bug compiles. The bidirectional type allows the collector to close, even though closing violates the sender-closes principle.

BIDIRECTIONAL CHANNELS: UNSAFE CONSUMER

A collector taking a bidirectional channel: it can receive, but it can also send into the channel it is meant to be draining, and close it. Both are bugs the signature does nothing to prevent.

Receive-only channels solve this:

collector_safe.go
// Illustrative snippet — not a complete program
func collectResults(
    ch <-chan Result,  // Receive-only
    count int,
) []Result {
    var out []Result
    for i := 0; i < count; i++ {
        out = append(out, <-ch)
    }

    close(ch)  // ✗ Compile error

    return out
}

The compiler enforces the sender-closes principle. The collector cannot close—it’s a compile-time guarantee. The type <-chan Result declares: “this function consumes results; it doesn’t control the channel’s lifecycle.”

RECEIVE-ONLY CHANNELS: SAFE CONSUMER

The same collector taking a receive-only channel. Send and close are both rejected by the compiler, leaving receive as the only thing the function can do with it.


Syntax

The arrow points out of the channel—values flow out to your code:

recvonly_syntax.go
// Illustrative snippet — not a complete program
<-chan T    // Receive-only: receive T values
// ^^^
//  └── Arrow points OUT OF chan: "receive from"

Memory aid: Read <-chan as “I receive from channel” or “values flow from channel to me.” The arrow shows the direction values flow from your code’s perspective.

RECEIVE-ONLY CHANNEL VISUAL

Three columns again, mirrored. An arrow runs from other code through the channel into your code; your attempt to send back into it is blocked. A receive-only channel lets you pull values out and never push them in.


Permitted and Prohibited Operations

A receive-only channel permits receiver operations and prohibits sender operations:

Receive-Only Channel Operations
<-ch (receive)
✓ Permitted
v, ok := <-ch
✓ Permitted
for v := range ch
✓ Permitted
ch <- v (send)
✗ Compile error
close(ch)
✗ Compile error
len(ch), cap(ch)
✓ Permitted

See Section 6.3 for the complete matrix comparing all channel types.

recvonly_operations.go
// Illustrative snippet — not a complete program
// Won't compile—demonstrates valid and invalid operations
func demonstrateReceiveOnly(ch <-chan int) {
    v := <-ch               // ✓ Receive permitted
    v, ok := <-ch           // ✓ Comma-ok permitted
    _ = ok

    for value := range ch { // ✓ Range permitted
        fmt.Println(value)
    }

    _ = len(ch)             // ✓ Buffer inspection
    _ = cap(ch)             // ✓ Capacity inspection

    ch <- 42                // ✗ Compile error
    close(ch)               // ✗ Compile error

    _ = v
}

Compiler errors:

Output
./main.go:17:2: invalid operation: cannot send to
    receive-only channel <-chan int ch (variable of type <-chan int)
./main.go:18:8: invalid operation: cannot close
    receive-only channel ch (variable of type <-chan int)
NIL DIRECTIONAL CHANNELS

Directionality doesn’t change nil channel behavior (Section 3.4):

nil_directional.go
// Illustrative snippet — not a complete program
var sendOnly chan<- int  // nil
var recvOnly <-chan int  // nil

sendOnly <- 42   // Blocks forever (goroutine leak)
v := <-recvOnly  // Blocks forever (goroutine leak)
close(sendOnly)  // Panic: close of nil channel

The directional restriction is orthogonal to nil behavior. Always initialize channels with make() before use.


Why Receive-Only Channels Cannot Close

Unlike send-only channels (which can close), receive-only channels cannot close. This asymmetry reflects the sender-closes principle from Section 3.3:

Closing is a sender operation. It signals: “No more values will be sent.” Only code that sends—or coordinates senders—has the knowledge to make this declaration. Receivers don’t know if other senders exist or when senders will finish.

CLOSING: SENDER VS RECEIVER KNOWLEDGE

Two perspectives on closing. The sender knows it has sent its last value and can say so. The receiver only knows it has not received anything lately, which is not the same thing — and is why closing belongs to the sender.


Conversion: Bidirectional to Receive-Only

Go implicitly converts bidirectional channels to receive-only. This is safe because it removes capabilities:

recvonly_conversion.go
// Illustrative snippet — not a complete program
func main() {
    ch := make(chan int)    // Bidirectional

    var recvOnly <-chan int = ch  // ✓ Implicit
    v := <-recvOnly                // ✓ Can receive

    // Reverse is NOT allowed:
    var bidir chan int = recvOnly  // ✗ Compile error
    _ = v
}

The same conversion rules and “create bidirectional, pass directional” pattern from Section 6.1 apply symmetrically. The reverse conversion (<-chan Tchan T) is a compile error—restrictions are permanent.


What Receive-Only Prevents

The primary value of <-chan T is preventing two classes of bugs that commonly occur when consumers have too much power:

1. Accidental Sends (Feedback Loops)

feedback_loop.go
// Illustrative snippet — not a complete program
// ✗ WITHOUT receive-only: bug compiles
func processResults(results chan int) {
    for v := range results {
        if v%2 == 0 {
            results <- v * 2  // Oops: sends back to own input
            // Deadlock (unbuffered) or infinite loop (buffered)
        }
        handle(v)
    }
}

// ✓ WITH receive-only: bug caught
func processResults(results <-chan int) {
    for v := range results {
        if v%2 == 0 {
            results <- v * 2  // ✗ Compile error
        }
        handle(v)
    }
}

In the broken version, the consumer sends values back into the channel it’s consuming. With an unbuffered channel this deadlocks immediately; with a buffered channel it creates an infinite loop. Receive-only types make this bug a compile error.

2. Consumer Closing (Violates Sender-Closes)

consumer_close.go
// Illustrative snippet — not a complete program
// ✗ WITHOUT receive-only: violates sender-closes
func consumer(data chan int) {
    for v := range data {
        process(v)
    }
    close(data)  // Double close: range exited because sender closed
}

// ✓ WITH receive-only: compiler enforces
func consumer(data <-chan int) {
    for v := range data {
        process(v)
    }
    // close(data) ✗ Compile error
}

Consumers should never close channels (Section 3.3). Receive-only types make this violation impossible.


The Idiomatic Pattern: for range

The most common use of receive-only channels is with for range:

for_range.go
// Illustrative snippet — not a complete program
func processStream(data <-chan int) {
    for value := range data {
        process(value)
    }
    // Loop exits when channel closes
}

The for range statement only receives from the channel, making it the natural fit for <-chan T. This is the idiomatic way to consume values until the producer closes.


Directional Channels in Select

Directional types work naturally with select. Each channel can only appear in cases matching its direction:

directional_select.go
// Illustrative snippet — not a complete program
func processWithShutdown(
    in <-chan int,
    out chan<- int,
    done <-chan struct{},
) {
    for {
        select {
        case v, ok := <-in:   // ✓ Receive
            if !ok {
                return        // Input closed
            }
            out <- v * 2       // ✓ Send
        case <-done:          // ✓ Receive
            return            // Shutdown
        }
    }
}

The compiler enforces correct usage:

This compile-time checking prevents entire categories of bugs.


Complete Example: Worker Pool

worker_pool.go
package main

import (
    "fmt"
    "sync"
    "time"
)

type Task struct {
    ID   int
    Data string
}

// Worker receives tasks, processes them. It takes a receive-only
// channel: the signature says it consumes and never closes.
func worker(id int, tasks <-chan Task) {
    for task := range tasks {
        fmt.Printf(
            "Worker %d processing task %d: %s\n",
            id, task.ID, task.Data,
        )
        time.Sleep(50 * time.Millisecond)
    }

    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    tasks := make(chan Task, 10)
    var wg sync.WaitGroup

    // Start 3 workers
    for i := 1; i <= 3; i++ {
        wg.Go(func() { worker(i, tasks) })
    }

    // Producer sends tasks
    for i := 1; i <= 9; i++ {
        tasks <- Task{
            ID:   i,
            Data: fmt.Sprintf("data-%d", i),
        }
    }

    close(tasks) // Producer closes
    wg.Wait()    // Wait for workers
}
Output (one real run — pairing varies)
Worker 1 processing task 1: data-1
Worker 2 processing task 3: data-3
Worker 3 processing task 2: data-2
Worker 1 processing task 4: data-4
Worker 3 processing task 5: data-5
Worker 2 processing task 6: data-6
Worker 3 processing task 7: data-7
Worker 1 processing task 8: data-8
Worker 2 processing task 9: data-9
Worker 2 finished
Worker 3 finished
Worker 1 finished

Key points:

  1. worker receives <-chan Task—compiler ensures it can only receive
  2. Workers cannot send tasks back or close the channel
  3. Producer (main) closes the channel when done sending
  4. All workers exit cleanly when the channel closes (via for range)
  5. Multiple consumers share one channel—each task delivered to exactly one worker (fan-out pattern, Chapter 7)

Receive-Only as Return Type (Preview)

Receive-only channels are commonly used as return types:

return_recvonly.go
// Illustrative snippet — not a complete program
func generateNumbers(count int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 0; i < count; i++ {
            out <- i
        }
    }()
    return out  // Caller gets receive-only
}

This pattern—returning <-chan T to provide a read-only stream—is powerful for API design. The function retains internal control (to send and close), while giving the caller only consumption rights. We explore this in depth in Section 6.5 (Output Channel Pattern).


Common Mistakes

Mistake 1: Creating Directional Channels Directly

mistake1_make_recvonly.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Legal Go, but useless—no one can send
recvOnly := make(<-chan int)  // Dead channel

// ✓ CORRECT: Make bidirectional, convert when passing
ch := make(chan int)
consumer(ch)  // Implicit conversion to <-chan int

Mistake 2: Returning Bidirectional When Receive-Only Suffices

mistake2_return_bidir.go
// Illustrative snippet — not a complete program
// ✗ SUBOPTIMAL: Exposes too much capability
func generate() chan int {
    ch := make(chan int)
    go func() {
        ch <- 1; ch <- 2
        close(ch)
    }()
    return ch  // Caller can send to or close this!
}

// ✓ BETTER: Return receive-only (Section 6.5)
func generate() <-chan int {
    ch := make(chan int)
    go func() {
        ch <- 1; ch <- 2
        close(ch)
    }()
    return ch  // Caller can only receive
}

Key Takeaways

  1. <-chan T permits only receive and range—send and close are compile errors
  2. Cannot close (unlike send-only)—receivers lack lifecycle knowledge, enforcing the sender-closes principle
  3. Prevents feedback loops and consumer-closes bugs—two common categories caught at compile time
  4. Create bidirectional, pass directionalchan T<-chan T conversion is implicit and permanent
  5. The type signature documents intent—readers know the function consumes values without reading the body

Next: Section 6.3 examines compile-time safety guarantees—what directional types prevent, what they don’t, and how to design APIs that maximize compiler-enforced correctness.

Section 6.2 — in one line

<-chan T is the fully constrained one: receive, comma-ok, range, len and cap are permitted; sending and closing are compile errors. That is what makes it the right return type for anything handing a stream to a caller.


6.3 Compile-Time Safety Guarantees

Sections 6.1 and 6.2 established that directional channels restrict operations at compile time. But what exactly does "compile-time safety" mean? And what problems remain beyond the compiler's reach?

Consider these two bugs:

compile_boundary.go
// Illustrative snippet — not a complete program
// Bug 1: Consumer closes channel
func consumer(ch <-chan int) {
    for v := range ch {
        process(v)
    }
    close(ch)  // ✗ Compile error
}

// Bug 2: Multiple producers close shared channel
func worker(id int, out chan<- int) {
    out <- id * 10
    close(out)  // ✓ Compiles—but causes panic
}

The first bug cannot compile. The type system prevents it absolutely. The second bug compiles successfully and manifests at runtime as a panic.

Compile-Time vs Runtime Safety

A two-column summary of the boundary. On one side, what the type system prevents outright: consumers sending into inputs, consumers closing, producers receiving their own output, cross-direction conversions. On the other, what it cannot: multiple producers closing a shared channel, send after close inside one function, leaks, and deadlocks.

This section examines the boundary: what directional types guarantee, what they don't, and how to design APIs that maximize compiler-enforced correctness.


What the Compiler Enforces

Directional channel types provide compile-time guarantees—violations are impossible to express in valid Go code. The compiler rejects the program before it runs.

Complete Channel Operations Reference

This is the authoritative reference for all channel type operations:

Complete Channel Operations Reference

An operations matrix. Rows are send, receive, comma-ok, range, close, len and cap; columns are the bidirectional, send-only and receive-only types. Send-only permits send, close, len and cap. Receive-only permits receive, comma-ok, range, len and cap. Bidirectional permits everything.

Red ✗ = compile error. These operations are impossible to express in valid Go code.

Every in this table represents a class of bugs that cannot exist in your program. The compiler makes them impossible.


What Directional Types Prevent

Sections 6.1 and 6.2 demonstrated three compile-time guarantees: consumers cannot send (preventing feedback loops), consumers cannot close (enforcing sender-closes), and producers cannot receive (enforcing role separation). A fourth guarantee completes the picture:

Conversions Are One-Way Only

Capability can only be removed, never added:

one_way_conversions.go
// Illustrative snippet — not a complete program
ch := make(chan int)

// Removing capability: always allowed
var sendOnly chan<- int = ch   // ✓
var recvOnly <-chan int = ch   // ✓

// Adding capability: always forbidden
var bidir1 chan int = sendOnly  // ✗ Compile error
var bidir2 chan int = recvOnly  // ✗ Compile error

// Cross-conversion: always forbidden
var send2 chan<- int = recvOnly  // ✗ Compile error
var recv2 <-chan int = sendOnly  // ✗ Compile error

// Even through any: runtime panic
var a any = sendOnly
_ = a.(chan int)  // panic: interface conversion

Once you restrict a channel reference, that restriction cannot be circumvented.


What Directional Types Do NOT Prevent

Directional types are powerful but not complete. Several critical bugs remain runtime concerns.

Limitation 1: Multiple Producers Closing

multi_close_panic.go
// Illustrative snippet — not a complete program
// ✓ COMPILES but causes runtime panic
func worker1(out chan<- int) {
    out <- 1
    close(out)  // Whichever worker gets here first
}

func worker2(out chan<- int) {
    out <- 2
    close(out)  // Rarely reached: the send above
              // panics first if worker1 closed
}

func main() {
    ch := make(chan int, 2)  // Buffer lets both send
    go worker1(ch)
    go worker2(ch)
    fmt.Println(<-ch, <-ch)  // Receive both values
    time.Sleep(10 * time.Millisecond)
}

Why types can't help: Both chan<- T references can close. The compiler doesn't track whether another goroutine has already closed.

Prevention: Use ownership patterns (Section 6.4)—only the coordinator closes.

Limitation 2: Send After Close

send_after_close.go
// Illustrative snippet — not a complete program
// ✓ COMPILES but panics at runtime
func producer(out chan<- int) {
    out <- 1
    close(out)
    out <- 2  // Panic: send on closed channel
}

Why types can't help: chan<- T can both send and close. The compiler doesn't track channel state.

Prevention: Use defer close() to ensure close happens at function exit.

Limitation 3: Goroutine Leaks

goroutine_leak.go
// Illustrative snippet — not a complete program
// ✓ COMPILES but goroutine leaks
func leakyProducer(out chan<- int) {
    for i := 0; ; i++ {
        out <- i  // Blocks forever if receiver stops
    }
}

func main() {
    ch := make(chan int)
    go leakyProducer(ch)

    fmt.Println(<-ch)  // Receive one value
    // Stop receiving—producer leaks forever
}

Why types can't help: Types restrict operations, not duration. Cancellation requires explicit coordination (done channels or Chapter 13: Context).

Prevention: Accept context.Context and check ctx.Done() in select.

Limitation 4: Deadlocks

deadlock.go
// Illustrative snippet — not a complete program
// ✓ COMPILES but deadlocks
func main() {
    ch := make(chan int)  // Unbuffered

    go func() {
        <-ch  // Blocks waiting to receive
    }()

    <-ch  // Main also blocks—no sender exists
    // Runtime: "all goroutines are asleep - deadlock!"
}

Why types can't help: Types verify what operations are present, not what operations should be present. Missing sends or receives can't be detected from types alone.

Prevention: Design review, testing, and Go’s runtime deadlock detector (Chapter 10).

Limitation 5: Data Races on Sent Values

data_race.go
// Illustrative snippet — not a complete program
// ✓ COMPILES but has data race
func main() {
    ch := make(chan *Data)
    data := &Data{Value: 1}

    go sender(ch, data)  // May access data anytime

    data.Value = 2  // DATA RACE

    <-ch
}

func sender(out chan<- *Data, d *Data) {
    d.Value = 3  // DATA RACE: concurrent modification
    out <- d     // Channel op is safe; data is not
}

Why types can't help: Channel types restrict channel operations, not pointer operations. The underlying data can be accessed by both sender and receiver.

Ownership Transfer is Convention, Not Enforcement

When you send a pointer or slice through a channel, you’re conventionally transferring ownership—the sender should stop using it. But the compiler cannot enforce this:

ownership_transfer.go
// Illustrative snippet — not a complete program
func sender(out chan<- []int) {
    data := []int{1, 2, 3}
    out <- data
    data[0] = 999  // Legal but wrong—data was "transferred"
}

Safe options: send copies, send immutable data, or use value types (not references).


Capability vs. Convention

A key insight: directional types express capability, but correct usage often requires convention.

Capability vs Convention

A table separating capability from convention. A receive-only channel is fully constrained by the type. A send-only channel is only partly constrained, because closing remains legal — so avoiding a double close is still a convention the type cannot enforce.

Receive-only is fully constrained—the type prevents all misuse. Send-only permits closing, so convention determines correctness in multi-producer scenarios.


Maximizing Compile-Time Safety

Given these boundaries, design APIs that maximize what the compiler catches.

Principle 1: Least Privilege

Give each function only the channel capabilities it needs.

least_privilege.go
// Illustrative snippet — not a complete program
// ✗ WEAK: Exposes too much capability
func process(ch chan int) {
    for v := range ch {
        handle(v)
    }
}

// ✓ STRONG: Minimal capability—bugs impossible
func process(ch <-chan int) {
    for v := range ch {
        handle(v)
    }
}

With chan int, a bug like ch <- v compiles. With <-chan int, it's impossible.

Principle 2: Return Receive-Only from Generators

Returning <-chan T instead of chan T protects the producer’s control—callers can only consume, never send or close. Sections 6.1 and 6.2 both demonstrated this pattern; Section 6.5 (Output Channel Pattern) explores it in depth.

Principle 3: Bidirectional Only for Owners

Reserve chan T for channel owners—functions that create channels and control their lifecycle:

owner_only_bidir.go
// Illustrative snippet — not a complete program
func coordinator() {
    ch := make(chan int)  // Owner holds chan T

    go producer(ch)       // Receives chan<- int
    go consumer(ch)       // Receives <-chan int

    // Owner controls lifecycle
}

func producer(out chan<- int) { /* sends only */ }
func consumer(in <-chan int)  { /* receives only */ }

Only the creator has bidirectional access. Everyone else gets restricted views.

Principle 4: Document Closing Responsibility

When a function receives chan<- T, document whether it should close:

document_closing.go
// Illustrative snippet — not a complete program
// generateFibonacci sends n Fibonacci numbers
// then closes out.
func generateFibonacci(
    out chan<- int,
    n int,
) {
    defer close(out)  // This function owns closing
    a, b := 0, 1
    for i := 0; i < n; i++ {
        out <- a
        a, b = b, a+b
    }
}

// worker sends results to out.
// Caller is responsible for closing out
// after all workers complete.
func worker(
    id int,
    in <-chan Task,
    out chan<- Result,
) {
    for task := range in {
        out <- process(task)
    }
    // Does NOT close—coordinator closes
}

The type permits closing in both cases. Documentation clarifies responsibility.


Zero Runtime Cost

Directional channel types are a compile-time feature only:

  • Zero memory overhead: chan T, chan<- T, and <-chan T are identical at runtime
  • Zero performance overhead: No runtime checks or type conversions
  • Zero allocation overhead: Conversion from chan T to directional is purely a compile-time annotation—at runtime, all channel types are represented identically

The safety is completely free. Use directional types everywhere—they make code safer without any performance cost.


Summary

What You Get and What You Don't

Two lists. The absolute compile-time guarantees, each paired with the bug it removes. And the guarantees the type system does not give, each paired with the discipline that has to cover it instead.

Key Takeaways

  1. Compile-time guarantees are absolute—wrong-direction operations and reverse conversions cannot exist in valid code
  2. Receive-only is fully constrained; send-only is partial<-chan T prevents all misuse, but chan<- T still permits closing (convention needed for multi-producer)
  3. Types enforce capability, not correctness—goroutine leaks, deadlocks, and data races on sent references remain runtime concerns
  4. Least privilege: give functions only what they need—reserve chan T for owners; everyone else gets a directional view
  5. Zero runtime cost—directional types are purely compile-time; use them everywhere

Next: Section 6.4 explores ownership patterns—the conventions that determine who creates channels, who sends, who receives, and who closes, building on type safety to create correct, maintainable concurrent systems.

Section 6.3 — in one line

Directional types catch four whole classes of bug at compile time, and are honest about four more they cannot touch—multiple producers closing, send-after-close inside one function, leaks, and deadlocks. Knowing which list a hazard is on tells you whether to reach for a type or a pattern.


6.4 Ownership Patterns: Who Creates, Who Closes

Sections 6.1–6.3 established what directional types enforce and what they don’t. The type chan<- T permits closing—but that doesn’t mean every function with chan<- T should close. Ownership patterns determine who should.

Consider this buggy worker pool:

ownership_bug.go
// Illustrative snippet — not a complete program
func main() {
    tasks := make(chan Task, 100)
    results := make(chan Result, 100)

    // Start 5 workers
    for i := 0; i < 5; i++ {
        go worker(tasks, results)
    }

    // Send tasks
    for _, t := range getAllTasks() {
        tasks <- t
    }
    close(tasks)  // Signal no more tasks

    // Collect results—but who closes results?
    for r := range results {
        process(r)
    }
}

func worker(tasks <-chan Task, results chan<- Result) {
    for task := range tasks {
        results <- process(task)
    }
    close(results)  // BUG: Each worker closes!
}

Each worker closes results when it finishes. The first worker to finish closes successfully. The second causes panic: close of closed channel. Even if that didn’t panic, the main goroutine’s range would exit prematurely.

The type chan<- Result permits closing. The bug is an ownership violation: workers don’t own the results channel—they share it.

OWNERSHIP DETERMINES CLOSING RESPONSIBILITY

The question posed as a decision. The wrong answer is whoever finishes first, which is what produces double closes. The right answer follows ownership: whoever created the channel and is the only one sending on it.

This section defines five ownership patterns that cover virtually all concurrent Go code.


The Four Questions of Channel Ownership

For every channel in your program, answer these questions:

  1. Who creates the channel? (The creator typically owns it)
  2. Who sends to the channel? (One sender? Many?)
  3. Who receives from the channel? (One receiver? Many?)
  4. Who closes the channel? (Only the owner should close)
CHANNEL OWNERSHIP DECISION TREE

A decision tree starting from who creates the channel. Each branch leads to a rule about who is then responsible for closing it, and to the ownership pattern that fits.


Pattern 1: Single Producer (Creator Closes)

The simplest pattern: one goroutine creates a channel, sends values, and closes when done.

pattern1_single_producer.go
// Illustrative snippet — not a complete program
func generateNumbers(max int) <-chan int {
    out := make(chan int)

    go func() {
        defer close(out)  // Producer owns; producer closes
        for i := 0; i < max; i++ {
            out <- i
        }
    }()

    return out
}

func main() {
    for n := range generateNumbers(5) {
        fmt.Println(n)
    }
}
Pattern 1: Ownership Analysis
Who creates?
generateNumbers (internal goroutine)
Who sends?
Single producer (the goroutine)
Who receives?
Caller (via returned <-chan int)
Who closes?
Producer (owns channel, knows when done)

Key insight: The producer knows when it’s done sending. No coordination needed—just defer close().

PATTERN 1: SINGLE PRODUCER

Pattern one: a single producer creates the channel, sends on it, and closes it before returning. One arrow in, one close, no coordination needed.


Pattern 2: Multiple Producers with Coordinator

When multiple goroutines send to one channel, a coordinator must close—not individual producers.

pattern2_coordinator.go
// Illustrative snippet — not a complete program
func processInParallel(tasks []Task) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup

    // Launch workers
    for _, task := range tasks {
        wg.Go(func() {
            results <- process(task)
        })
    }

    // Coordinator closes after all workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    return results
}
Pattern 2: Ownership Analysis
Who creates?
processInParallel
Who sends?
Multiple workers
Who receives?
Caller (via returned <-chan Result)
Who closes?
Coordinator goroutine (after wg.Wait())

Key insight: Individual workers don’t know when all workers are done. Only the coordinator—via WaitGroup—has that knowledge.

PATTERN 2: COORDINATOR CLOSES

Pattern two: several producers send into one channel and none of them may close it. A separate coordinator waits for all of them and performs the single close.

Why a Separate Closer Goroutine?

This pattern trips up many developers:

closer_goroutine.go
// Illustrative snippet — not a complete program
go func() {
    wg.Wait()
    close(results)
}()

Why not close after waiting in the main function?

deadlock_example.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Deadlocks
func coordinator(tasks []Task) []Result {
    results := make(chan Result)
    var wg sync.WaitGroup

    // ... spawn workers ...

    wg.Wait()       // ← Blocks here forever
    close(results)  // Never reached

    var collected []Result
    for r := range results {
        collected = append(collected, r)
    }
    return collected
}

The deadlock: wg.Wait() blocks until workers call wg.Done(). But workers can’t complete their sends to results until someone receives from results. The coordinator is waiting for workers; workers are waiting for the coordinator. Classic deadlock.

The solution: A separate closer goroutine runs concurrently with consumption:

CONCURRENT CLOSER PATTERN

A timeline of the coordinator pattern. The caller receives the channel and starts ranging over it immediately, while a closer goroutine waits on the WaitGroup and closes afterwards — which is why the wait must not happen before the caller starts consuming.

  1. Function returns channel immediately
  2. Caller starts consuming (draining the channel)
  3. Workers send results as they complete
  4. Closer goroutine waits for all workers (wg.Wait())
  5. When last worker finishes, closer calls close(results)
  6. Consumer’s range terminates

This pattern appears constantly in production Go code.


Pattern 3: Passed-In Channel (Caller Owns)

Sometimes the caller creates and owns the channel, passing it to functions that contribute values:

pattern3_passed_in.go
// Illustrative snippet — not a complete program
func main() {
    results := make(chan int, 100)  // Caller creates and owns
    var wg sync.WaitGroup

    // Multiple contributors
    wg.Add(3)
    go contribute(1, 10, results, &wg)
    go contribute(11, 20, results, &wg)
    go contribute(21, 30, results, &wg)

    // Caller closes after all contributors finish
    go func() {
        wg.Wait()
        close(results)  // Owner closes
    }()

    for r := range results {
        fmt.Println(r)
    }
}

// contribute sends values to out.
// Caller is responsible for closing out
// after all contributors complete.
func contribute(
    start, end int,
    out chan<- int,
    wg *sync.WaitGroup,
) {
    defer wg.Done()
    for i := start; i <= end; i++ {
        out <- i
    }
    // Does NOT close—caller owns the channel
}
Pattern 3: Ownership Analysis
Who creates?
Caller (main)
Who sends?
Multiple contributors
Who receives?
Caller
Who closes?
Caller (after coordinating contributors)

Key insight: The function receives chan<- T but doesn’t close—the caller retains ownership. Documentation must clarify this.

PATTERN 3: CALLER OWNS

Pattern three: the caller creates the channel and passes it in. The function sends but must not close, because it does not own what it did not create.


Pattern 4: Transformer (Non-Owner)

A transformer receives from one channel and sends to another—owning neither:

pattern4_transformer.go
// Illustrative snippet — not a complete program
func square(in <-chan int, out chan<- int) {
    for v := range in {
        out <- v * v
    }
    // Does NOT close out—caller owns both
}

func main() {
    input := make(chan int)
    output := make(chan int)

    go func() {
        defer close(input)  // Owner closes
        for i := 1; i <= 5; i++ {
            input <- i
        }
    }()

    go func() {
        square(input, output)
        close(output)  // Caller closes after transformer
    }()

    for v := range output {
        fmt.Println(v)
    }
}
Pattern 4: Ownership Analysis
Who creates input?
Caller (main)
Who creates output?
Caller (main)
Who sends to input?
Producer
Who sends to output?
Transformer
Who closes input?
Producer (owns it)
Who closes output?
Caller (after transformer completes)

Key insight: The transformer’s signature (in <-chan T, out chan<- T) shows it owns neither channel. Closing responsibility lies with whoever owns each channel.

PATTERN 4: TRANSFORMER (NON-OWNER)

Pattern four: a transformer sits between a producer and a consumer. It receives from an upstream channel it does not own, and creates and closes a new downstream channel that it does.

TRANSFORMER CLOSING PATTERN

A common variation: the goroutine running the transformer closes output when the transformer returns:

transformer_closing.go
// Illustrative snippet — not a complete program
go func() {
    square(input, output)  // Returns when input closes
    close(output)          // Then close output
}()

This chains closure: when input closes, square exits, then output closes. The transformer itself doesn’t close; the wrapping goroutine does.


Pattern 5: Long-Lived Channels (Never Close)

Some channels live for the program’s duration—closing them isn’t necessary or useful:

pattern5_long_lived.go
// Illustrative snippet — not a complete program
var (
    eventBus = make(chan Event, 100)
)

func main() {
    go eventProcessor()

    // Events generated throughout
    // program lifetime
    eventBus <- Event{Type: "startup"}

    // ... rest of program ...

    // Channel never closed—program
    // terminates instead
}

func eventProcessor() {
    for event := range eventBus {
        handle(event)
    }
}
Pattern 5: Ownership Analysis
Who creates?
Package initialization
Who sends?
Many senders throughout program
Who receives?
Event processor
Who closes?
Nobody—program termination handles cleanup

Key insight: Closing signals “no more values.” For channels that logically never run out of values, closing is meaningless. Program termination cleans up.

WHEN NOT TO USE THIS PATTERN

Long-lived channels work for program-lifetime resources. They don’t work when you need graceful shutdown:

no_graceful_shutdown.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Can't signal processor to exit gracefully
func stopProcessing() {
    // How do we tell eventProcessor to stop?
    // Closing eventBus would require
    // coordinating all senders
}

For graceful shutdown, use context cancellation (Chapter 13) alongside the channel:

graceful_shutdown.go
// Illustrative snippet — not a complete program
func eventProcessor(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return  // Graceful exit
        case event := <-eventBus:
            handle(event)
        }
    }
}

Ownership Patterns Quick Reference

Ownership Patterns
Single Producer
Creates: Producer · Sends: Producer · Closes: Producer
Key code: defer close(out)
Use case: Generators, streams
Coordinator
Creates: Coordinator · Sends: N workers · Closes: Coordinator
Key code: go func() { wg.Wait(); close(ch) }()
Use case: Worker pools, fan-in
Passed-In
Creates: Caller · Sends: Function · Closes: Caller
Key code: Document “caller closes”
Use case: Reusable components
Transformer
Creates: Caller (both) · Sends: Transformer · Closes: Caller
Key code: func(in <-chan T, out chan<- U)
Use case: Pipeline stages
Long-Lived
Creates: Service · Sends: Multiple · Closes: Never
Key code: Context for shutdown
Use case: Server channels

Quick decision rule: If you create the channel, you own its lifecycle (including closing).


Choosing the Right Pattern

Use this decision tree:

OWNERSHIP PATTERN DECISION TREE

A decision tree for choosing among the ownership patterns, starting from whether your function creates the channel at all, then how many senders there are, then who needs to observe the close.


Common Mistakes and Fixes

Mistake 1: Worker Closes Shared Channel

mistake1_worker_close.go
// Illustrative snippet — not a complete program
// ✗ WRONG
func worker(results chan<- int) {
    results <- compute()
    close(results)  // Other workers will panic
}

// ✓ CORRECT
func worker(results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    results <- compute()
    // Coordinator closes after wg.Wait()
}

Mistake 2: Forgetting to Close

mistake2_forget_close.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Consumer blocks forever
func generate(out chan<- int) {
    for i := 0; i < 10; i++ {
        out <- i
    }
    // Forgot to close!
}

// ✓ CORRECT: Sole sender closes
// (closing responsibility delegated by caller)
func generate(out chan<- int) {
    defer close(out)
    for i := 0; i < 10; i++ {
        out <- i
    }
}

Mistake 3: Closing Before All Sends Complete

mistake3_early_close.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Race between send and close
func process(in <-chan int, out chan<- int) {
    for v := range in {
        go func() {
            out <- v * 2  // May happen after close!
        }()
    }
    close(out)  // Closes while goroutines still sending
}

// ✓ CORRECT: Wait for all goroutines
// process acts as coordinator for its internal
// goroutines, so it closes out when they finish.
func process(in <-chan int, out chan<- int) {
    var wg sync.WaitGroup
    for v := range in {
        wg.Go(func() {
            out <- v * 2
        })
    }
    wg.Wait()
    close(out)
}

Note: Unlike Mistake 4 below, process spawns internal goroutines that send to out—it acts as a coordinator (Pattern 2) for its workers, not a pure transformer. The closing responsibility is part of its API contract.

Mistake 4: Transformer Closing Passed-In Channel

mistake4_transformer_close.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Transformer closes channel it doesn't own
func double(in <-chan int, out chan<- int) {
    for v := range in {
        out <- v * 2
    }
    close(out)  // Wrong—caller owns out
}

// ✓ CORRECT: Transformer owns neither channel
func double(in <-chan int, out chan<- int) {
    for v := range in {
        out <- v * 2
    }
    // Caller closes out when appropriate
}

Key Takeaways

  1. Ownership determines closing responsibility—the creator owns the lifecycle
  2. Multiple producers need a coordinator—WaitGroup + separate closer goroutine prevents deadlock
  3. Passed-in channels and transformers don’t close—caller retains ownership
  4. Workers never close shared channels—they don’t know when all work is done
  5. Long-lived channels may never close—use context for graceful shutdown

Next: Section 6.5 introduces the output channel pattern—functions that create channels, spawn producers, and return receive-only references. This pattern encapsulates concurrency behind clean APIs.

Section 6.4 — in one line

Closing belongs to whoever owns the channel, and ownership follows creation. One producer closes its own output; several producers hand the close to a coordinator; a caller who supplies the channel keeps the close; a transformer closes only the channel it made.


6.5 The Output Channel Pattern

Section 6.4 established ownership patterns—who creates, sends, and closes channels. The most powerful pattern for API design combines several principles: the function creates a channel, spawns a goroutine to produce values, and returns a receive-only reference. The caller gets a clean, simple API; the implementation details are encapsulated.

Consider the difference:

output_channel_intro.go
// Illustrative snippet — not a complete program
// ✗ CALLER MANAGES CONCURRENCY
func main() {
    ch := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            ch <- i
        }
        close(ch)
    }()

    for v := range ch {
        fmt.Println(v)
    }
}

// ✓ FUNCTION ENCAPSULATES CONCURRENCY
func main() {
    for v := range generate(10) {
        fmt.Println(v)
    }
}

func generate(n int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 0; i < n; i++ {
            out <- i
        }
    }()
    return out
}

The second version is cleaner: the caller doesn’t manage goroutines, channels, or closing. The function returns <-chan int—a stream of values. The implementation is hidden.

OUTPUT CHANNEL PATTERN

The output channel pattern in four numbered steps: create the channel inside the function, start a goroutine that sends into it, close it when the work is done, and return it narrowed to receive-only so no caller can interfere.

This is the output channel pattern: return <-chan T to provide a stream of values while hiding the production mechanism.


Anatomy of the Pattern

Every output channel function has four parts:

output_channel_anatomy.go
// Illustrative snippet — not a complete program
func producer() <-chan T {
    // 1. CREATE: Make the channel
    out := make(chan T)

    // 2. SPAWN: Start the producer goroutine
    go func() {
        // 3. CLOSE: Producer owns closing
        defer close(out)

        // Produce values
        for /* condition */ {
            out <- value
        }
    }()

    // 4. RETURN: Give caller receive-only access
    return out
}

Why this structure works:

  1. Create internally: Function controls channel configuration (buffered vs unbuffered)
  2. Spawn before return: Goroutine starts producing immediately
  3. defer close(out): Guarantees closure even on panic or early exit
  4. Return <-chan T: Caller can only receive—cannot send or close

Basic Examples

Finite Sequence Generator

countdown.go
// Illustrative snippet — not a complete program
func countdown(from int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := from; i >= 0; i-- {
            out <- i
        }
    }()
    return out
}

// Usage
for n := range countdown(5) {
    fmt.Println(n)  // 5, 4, 3, 2, 1, 0
}

Transforming a Slice

square_all.go
// Illustrative snippet — not a complete program
func squareAll(nums []int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n * n
        }
    }()
    return out
}

// Usage
for sq := range squareAll([]int{1, 2, 3, 4, 5}) {
    fmt.Println(sq)  // 1, 4, 9, 16, 25
}

File Line Reader

read_lines.go
// Illustrative snippet — not a complete program
func readLines(filename string) (<-chan string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, err
    }

    out := make(chan string)
    go func() {
        defer close(out)
        defer file.Close()

        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
            out <- scanner.Text()
        }
        // scanner.Err() ignored for brevity
    }()
    return out, nil
}

// Usage
lines, err := readLines("data.txt")
if err != nil {
    log.Fatal(err)
}
for line := range lines {
    process(line)
}
SIMPLIFIED FOR CLARITY

Error handling (e.g., scanner.Err()) is omitted here—the Error Handling subsection below covers production-ready patterns. Also note: if the consumer stops receiving early, the producer goroutine leaks. The Handling Infinite Producers subsection shows how context prevents this.


Why Return <-chan T Instead of chan T?

Returning the directional type provides compile-time protection:

directional_return.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Returns bidirectional
func generate() chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        ch <- 1
        ch <- 2
        ch <- 3
    }()
    return ch
}

func main() {
    ch := generate()

    ch <- 999     // Compiles! Corrupts the stream
    close(ch)     // Compiles! Causes panic in producer

    for v := range ch { /* ... */ }
}

// ✓ SAFE: Returns receive-only
func generate() <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        ch <- 1
        ch <- 2
        ch <- 3
    }()
    return ch
}

func main() {
    ch := generate()

    ch <- 999     // ✗ Compile error: cannot send
    close(ch)     // ✗ Compile error: cannot close

    for v := range ch { /* correct usage */ }
}

As Section 6.3 established, the receive-only type blocks sending and closing at compile time. Always return <-chan T from producer functions.


Handling Infinite Producers

What if the producer never naturally completes?

infinite_counter_bad.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Infinite producer with no shutdown
func infiniteCounter() <-chan int {
    out := make(chan int)
    go func() {
        // No defer close—never closes!
        for i := 0; ; i++ {
            out <- i  // If receiver stops, blocks forever
        }
    }()
    return out
}

func main() {
    ch := infiniteCounter()
    fmt.Println(<-ch)  // 0
    fmt.Println(<-ch)  // 1
    // Stop receiving—producer goroutine leaks forever
}

The producer blocks on out <- i when no one receives. The goroutine never exits—it leaks.

Solution: Use context for cancellation:

infinite_counter_ctx.go
// Illustrative snippet — not a complete program
func infiniteCounter(
    ctx context.Context,
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 0; ; i++ {
            select {
            case <-ctx.Done():
                return  // Exit when cancelled
            case out <- i:
            }
        }
    }()
    return out
}

func main() {
    ctx, cancel := context.WithCancel(
        context.Background(),
    )
    defer cancel()  // Ensure cleanup

    ch := infiniteCounter(ctx)

    for i := 0; i < 5; i++ {
        fmt.Println(<-ch)
    }

    cancel()  // Signal producer to stop
    // Producer exits cleanly, goroutine doesn't leak
}
INFINITE PRODUCER WITH CANCELLATION

The same pattern for a producer with no natural end. A context is threaded in, the send is wrapped in a select alongside ctx.Done, and cancellation is what terminates the goroutine and closes the channel.

RULE: INFINITE PRODUCERS MUST ACCEPT CONTEXT

Any producer that doesn’t naturally terminate should accept context.Context and check ctx.Done() in a select. Otherwise, stopped consumers cause goroutine leaks.

Chapter 13 covers context patterns in depth.


Error Handling

Producers may encounter errors. Several patterns handle this:

Pattern 1: Result Struct

Bundle values and errors in a struct:

error_result_struct.go
// Illustrative snippet — not a complete program
type Result struct {
    Value int
    Err   error
}

func processItems(items []Item) <-chan Result {
    out := make(chan Result)
    go func() {
        defer close(out)
        for _, item := range items {
            value, err := process(item)
            out <- Result{Value: value, Err: err}
        }
    }()
    return out
}

// Usage
for result := range processItems(items) {
    if result.Err != nil {
        log.Printf("error: %v", result.Err)
        continue
    }
    handle(result.Value)
}

Advantages: Simple, all results delivered, caller decides how to handle errors.

Pattern 2: Separate Error Channel

Return two channels—one for values, one for errors:

error_separate_channel.go
// Illustrative snippet — not a complete program
func processItems(
    items []Item,
) (<-chan int, <-chan error) {
    values := make(chan int)
    errs := make(chan error, 1)  // Buffered

    go func() {
        defer close(values)
        defer close(errs)

        for _, item := range items {
            value, err := process(item)
            if err != nil {
                errs <- err
                return  // Stop on first error
            }
            values <- value
        }
    }()

    return values, errs
}

// Usage: drain values, then check error
values, errs := processItems(items)
for v := range values {
    handle(v)
}
if err := <-errs; err != nil {
    log.Printf("processing stopped: %v", err)
}

Advantages: Can stop on first error, errors are typed (not wrapped in struct).

Disadvantages: More complex consumption—need select to handle both channels.

Pattern 3: Error Callback

Accept an error handler function:

error_callback.go
// Illustrative snippet — not a complete program
func processItems(
    items []Item,
    onError func(error),
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, item := range items {
            value, err := process(item)
            if err != nil {
                onError(err)
                continue
            }
            out <- value
        }
    }()
    return out
}

// Usage
for value := range processItems(
    items,
    func(err error) {
        log.Printf("error: %v", err)
    },
) {
    handle(value)
}

Advantages: Clean consumption (single channel), flexible error handling.

Disadvantages: Callback invoked from producer goroutine—must be thread-safe.

DEFAULT CHOICE

The Result struct pattern is most common—simple consumption via single channel, all results delivered, works naturally with for range. Use separate error channels only when the first error should halt processing.


Common Variations

Buffered Output Channel

Use buffering when producer is faster than consumer:

buffered_output.go
// Illustrative snippet — not a complete program
func generate(n int) <-chan int {
    out := make(chan int, 100)  // Buffer reduces blocking
    go func() {
        defer close(out)
        for i := 0; i < n; i++ {
            out <- i
        }
    }()
    return out
}

Buffering lets the producer run ahead, reducing synchronization overhead.

Multiple Return Values

Return channel plus metadata:

multiple_return.go
// Illustrative snippet — not a complete program
func search(
    query string,
) (<-chan Result, int) {
    results := make(chan Result)
    totalExpected := estimateResults(query)

    go func() {
        defer close(results)
        for result := range executeSearch(query) {
            results <- result
        }
    }()

    return results, totalExpected
}

// Usage
results, total := search("golang")
fmt.Printf(
    "Expecting approximately %d results\n",
    total,
)
for r := range results {
    process(r)
}

Initialization Error Handling

Handle errors that occur before production starts:

init_error_handling.go
// Illustrative snippet — not a complete program
func streamFromDB(
    dsn string,
) (<-chan Record, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, fmt.Errorf("connect: %w", err)
    }

    rows, err := db.Query("SELECT * FROM records")
    if err != nil {
        db.Close()
        return nil, fmt.Errorf("query: %w", err)
    }

    out := make(chan Record)
    go func() {
        defer close(out)
        defer rows.Close()
        defer db.Close()

        for rows.Next() {
            var r Record
            if err := rows.Scan(&r.ID, &r.Name); err != nil {
                return
            }
            // A caller that abandons this channel strands the
            // goroutine here — and with it a pooled DB
            // connection, since the defers never run. A real
            // version takes a ctx and selects on ctx.Done().
            out <- r
        }
    }()

    return out, nil
}

Pattern: Return (<-chan T, error). Check error before consuming channel.


Pipeline Composition

Output channel functions compose naturally into pipelines:

pipeline.go
// Illustrative snippet — not a complete program
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

func filter(
    in <-chan int,
    predicate func(int) bool,
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            if predicate(n) {
                out <- n
            }
        }
    }()
    return out
}

func main() {
    // Pipeline: generate → square → filter
    numbers := generate(
        1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    )
    squared := square(numbers)
    evenOnly := filter(squared, func(n int) bool {
        return n%2 == 0
    })

    for n := range evenOnly {
        fmt.Println(n)  // 4, 16, 36, 64, 100
    }
}
PIPELINE COMPOSITION

Three stages composed into a pipeline: generate feeds square, square feeds filter. Each stage takes a receive-only input and returns a receive-only output, so the types alone describe the direction of flow through the whole chain.

Key property: Closing propagates forward. When generate closes its output, square’s range exits, so square closes its output, which causes filter to close its output. The consumer’s range then exits naturally. For large or infinite data, combine with context cancellation to enable backward shutdown—Chapter 7 covers this in detail.

PIPELINE CLOSURE PROPAGATION

generate closes → square’s range exits → square closes → filter’s range exits → filter closes → consumer’s range exits.

Each stage’s defer close(out) ensures closure propagates through the entire pipeline.


REAL-WORLD EXAMPLE: time.Tick

The Go standard library’s time.Tick demonstrates the output channel pattern:

func Tick(d Duration) <-chan Time

It returns a receive-only channel delivering time values at regular intervals. The function owns the channel’s lifecycle; callers can only receive.

The one thing it cannot do: time.Tick takes no context, so there is no way to tell it to stop. That is the argument for giving your own infinite generators a context parameter—not because the ticker leaks.

It is worth being precise here, because the old advice is still everywhere. A ticker is not a goroutine—it is an entry in the runtime’s timer heap, and always has been. Creating a thousand of them adds zero goroutines. And since Go 1.23 an unreferenced ticker is reclaimed by the garbage collector whether or not you stopped it: 200,000 abandoned tickers leave the heap flat — a couple of dozen live objects, not two hundred thousand. The standard library documentation now says so outright — “There is no longer any reason to prefer NewTicker when Tick will do.” This is the same Go 1.23 timer change Chapter 4 §4.4 works through for time.After.

Measured Measured on go1.26.1, darwin/amd64: across eight runs, 1,000 live tickers changed runtime.NumGoroutine() by zero every time, and 200,000 abandoned, never-stopped tickers changed HeapObjects by between +15 and +24 after a GC. Your own numbers will land somewhere else in that neighborhood; what will not change is that neither figure scales with the number of tickers.

When to Use the Output Channel Pattern

Good Use Cases
Stream generation
Sequences, transformations, data pipelines
Async computation
Background work returning results over time
Resource iteration
Files, database rows, network responses
Event streams
User input, system events, message queues
Poor Use Cases
Single value
Just return (T, error)—a channel is overkill
Known small collection
Return []T—simpler and often more efficient
Synchronous operation
If caller must wait anyway, return directly
when_to_use.go
// Illustrative snippet — not a complete program
// ✗ OVERKILL: Channel for single value
func fetchConfig() <-chan Config {
    out := make(chan Config, 1)
    go func() {
        defer close(out)
        out <- loadConfig()
    }()
    return out
}

// ✓ SIMPLER: Direct return
func fetchConfig() (Config, error) {
    return loadConfig()
}

// ✗ OVERKILL: Channel for small slice
func getTopThree() <-chan Item {
    out := make(chan Item)
    go func() {
        defer close(out)
        for _, item := range computeTopThree() {
            out <- item
        }
    }()
    return out
}

// ✓ SIMPLER: Return slice
func getTopThree() []Item {
    return computeTopThree()
}

Rule of thumb: Use output channels when values arrive over time or quantity is large/unknown. Use direct returns when you have all values immediately.


Common Mistake: Blocking Before Spawning

blocking_before_spawning.go
// Illustrative snippet — not a complete program
// ✗ WRONG: Blocks caller during setup
func readLargeFile(path string) (<-chan string, error) {
    // Reads the ENTIRE file before returning!
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    ch := make(chan string)
    go func() {
        defer close(ch)
        for _, line := range strings.Split(string(data), "\n") {
            ch <- line
        }
    }()
    return ch, nil
}

// ✓ CORRECT: Return immediately, do work in goroutine
func readLargeFile(path string) (<-chan string, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    ch := make(chan string)
    go func() {
        defer close(ch)
        defer file.Close()
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
            ch <- scanner.Text()
        }
    }()
    return ch, nil
}

Key Takeaways

  1. Return <-chan T with defer close(out)—the core pattern: create, spawn, close, return
  2. Infinite producers must accept context.Context—otherwise stopped consumers cause goroutine leaks
  3. Use Result structs for streaming errors; return (<-chan T, error) for initialization errors—fail fast before streaming
  4. Pipelines compose naturally—closing propagates through stages via defer close
  5. Don’t over-use—direct returns are simpler for single values or small collections

Next: Section 6.6 synthesizes everything—directional types, ownership patterns, and the output channel pattern—into comprehensive API design guidelines for concurrent Go functions.

Section 6.5 — in one line

Create inside, send from a goroutine, close when done, return <-chan T. The narrowing on the way out is what makes the lifecycle unforgeable by a caller—and for producers with no natural end, a context is what supplies the ending.


6.6 API Design Guidelines

Sections 6.1–6.5 introduced the building blocks: directional types for compile-time safety, ownership patterns for lifecycle management, and the output channel pattern for encapsulation. This section synthesizes these into practical guidelines for designing concurrent APIs.

Good concurrent API design answers three questions:

  1. What types? Which directional types express the function’s role?
  2. Who owns what? Who creates, sends, receives, and closes each channel?
  3. What patterns? Which established patterns apply?
API DESIGN: THE THREE QUESTIONS

Three questions to ask when designing a channel API: what capabilities each participant needs, who owns the channel's lifecycle, and how cancellation reaches the producer.


Principle 1: Use the Most Restrictive Type

Every function should receive the minimum capability it needs. This maximizes compile-time checking.

restrictive_types.go
// Illustrative snippet — not a complete program
// ✗ WEAK: Exposes full capability
func process(ch chan int) {
    for v := range ch {
        handle(v)
    }
}

// ✓ STRONG: Minimal capability
func process(ch <-chan int) {
    for v := range ch {
        handle(v)
    }
}
Decision Guide
Row
Only receives values
Only sends values
Sends and receives (rare)
Creates and controls lifecycle
TYPE SELECTION DECISION TREE

A decision tree for choosing a parameter type, starting from what the function actually does with the channel — sends only, receives only, or genuinely both.

BIDIRECTIONAL PARAMETERS ARE A CODE SMELL

If a function takes chan T, ask: does it really need both send and receive? Usually, the answer is no. Common exceptions: channel creation/ownership functions, tests that need full control, and adapters between differently-typed channels.


Principle 2: Return Receive-Only from Producers

Functions that produce values should return <-chan T, not chan T.

return_receive_only.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: Caller can interfere
func generate(n int) chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; i < n; i++ {
            ch <- i
        }
    }()
    return ch
}

// ✓ SAFE: Caller can only consume
func generate(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; i < n; i++ {
            ch <- i
        }
    }()
    return ch
}
What the Return Type Communicates
Row
<-chan T
chan T
chan<- T

Principle 3: Document Ownership Clearly

When ownership isn’t obvious from types, document it:

document_ownership.go
// Illustrative snippet — not a complete program
// ProcessTasks spawns workers to process tasks concurrently.
// The returned channel receives results as they complete.
// The channel closes automatically when all tasks finish.
func ProcessTasks(tasks []Task) <-chan Result {
    // ...
}

// SendResults sends processed results to out.
// Caller is responsible for closing out after all
// senders complete. This function does not close out.
func SendResults(results []Result, out chan<- Result) {
    for _, r := range results {
        out <- r
    }
}

// Transform reads from in and writes transformed
// values to out.
// This function does not close either channel.
// Caller is responsible for channel lifecycle.
func Transform(in <-chan int, out chan<- int) {
    for v := range in {
        out <- v * 2
    }
}

Documentation Checklist

  • Who creates each channel?
  • Who closes each channel?
  • What happens when input channels close?
  • Are there any blocking conditions?
  • Is context required for cancellation?

Principle 4: Accept Context for Long-Running Operations

Any function that might block indefinitely should accept context.Context:

context_operations.go
// Illustrative snippet — not a complete program
// ✗ DANGEROUS: No way to cancel
func streamEvents() <-chan Event {
    ch := make(chan Event)
    go func() {
        for {
            ch <- waitForEvent()
        }
    }()
    return ch
}

// ✓ SAFE: Cancellable
func streamEvents(ctx context.Context) <-chan Event {
    ch := make(chan Event)
    go func() {
        defer close(ch)
        for {
            select {
            case <-ctx.Done():
                return
            case ch <- waitForEvent():
            }
        }
    }()
    return ch
}
When to Require Context
Finite, fast operation
Optional
Infinite producer
Required
Network/IO operations
Required
Long-running computation
Required
Simple transformation
Optional

Principle 5: Prefer Returning Channels Over Accepting Them

When designing a producer, prefer the output channel pattern:

return_vs_accept.go
// Illustrative snippet — not a complete program
// ✗ AWKWARD: Caller must manage channel
func Generate(n int, out chan<- int) {
    defer close(out)
    for i := 0; i < n; i++ {
        out <- i
    }
}

// Usage is clunky:
ch := make(chan int)
go Generate(10, ch)
for v := range ch {
    // ...
}

// ✓ CLEANER: Function manages channel
func Generate(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 0; i < n; i++ {
            ch <- i
        }
    }()
    return ch
}

// Usage is simple:
for v := range Generate(10) {
    // ...
}

Exceptions where accepting a channel is appropriate:


Principle 6: Use Consistent Signatures Across Your API

Related functions should follow consistent patterns:

consistent_signatures.go
// Illustrative snippet — not a complete program
// ✓ CONSISTENT: All generators return <-chan T
func GenerateInts(n int) <-chan int { ... }
func GenerateStrings(words []string) <-chan string { ... }
func GenerateFromFile(path string) (<-chan Line, error) { ... }

// ✓ CONSISTENT: All transformers return <-chan T
func Map(in <-chan int, f func(int) int) <-chan int { ... }
func Filter(in <-chan int, pred func(int) bool) <-chan int { ... }
func Reduce(in <-chan int, f func(int, int) int) <-chan int { ... }

// ✓ CONSISTENT: All workers take <-chan Task, chan<- Result
func Worker(tasks <-chan Task, results chan<- Result) { ... }
func BatchWorker(tasks <-chan []Task, results chan<- []Result) { ... }

Consistency makes the API predictable and reduces cognitive load.


Common API Patterns

Pattern A: Generator Function

pattern_generator.go
// Illustrative snippet — not a complete program
func Generate(/* params */) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        // produce values
    }()
    return out
}

Use for: Creating streams of values from parameters or computation.

Pattern B: Generator with Context

pattern_generator_ctx.go
// Illustrative snippet — not a complete program
func Generate(ctx context.Context, /* params */) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for /* condition */ {
            select {
            case <-ctx.Done():
                return
            case out <- value:
            }
        }
    }()
    return out
}

Use for: Long-running or infinite producers.

Pattern C: Generator with Error

pattern_generator_err.go
// Illustrative snippet — not a complete program
func Generate(/* params */) (<-chan T, error) {
    // Initialization that might fail
    resource, err := acquire()
    if err != nil {
        return nil, err
    }

    out := make(chan T)
    go func() {
        defer close(out)
        defer resource.Release()
        // produce values
    }()
    return out, nil
}

Use for: Producers that require fallible initialization.

Pattern D: Transformer

pattern_transformer.go
// Illustrative snippet — not a complete program
func Transform(in <-chan T) <-chan U {
    out := make(chan U)
    go func() {
        defer close(out)
        for v := range in {
            out <- transform(v)
        }
    }()
    return out
}

Use for: Pipeline stages that transform streams.

Pattern E: Fan-In (Multiple Inputs)

pattern_fan_in.go
// Illustrative snippet — not a complete program
func Merge(ctx context.Context, channels ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Go(func() {
            for v := range ch {
                select {
                case <-ctx.Done():
                    return
                case out <- v:
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

Use for: Combining multiple streams into one.

Pattern F: Fan-Out (Multiple Outputs)

pattern_fan_out.go
// Illustrative snippet — not a complete program
func Broadcast(in <-chan T, n int) []<-chan T {
    outs := make([]chan T, n)
    for i := range outs {
        outs[i] = make(chan T)
    }

    go func() {
        defer func() {
            for _, out := range outs {
                close(out)
            }
        }()

        for v := range in {
            for _, out := range outs {
                out <- v
            }
        }
    }()

    // Convert to receive-only
    result := make([]<-chan T, n)
    for i, out := range outs {
        result[i] = out
    }
    return result
}

Use for: Sending values to multiple consumers. Note: a slow consumer blocks all others—Chapter 7 covers non-blocking broadcast variants.

Pattern G: Worker Pool

pattern_worker_pool.go
// Illustrative snippet — not a complete program
func ProcessWithWorkers(
    ctx context.Context,
    tasks <-chan Task,
    numWorkers int,
) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Go(func() {
            for task := range tasks {
                // Work happens BEFORE the select: all case
                // expressions are evaluated up front (Ch 4
                // §4.1), so process(task) written inside a
                // case would run even when ctx is done.
                r := process(task)

                select {
                case <-ctx.Done():
                    return
                case results <- r:
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(results)
    }()

    return results
}

Use for: Parallel processing with controlled concurrency.


Anti-Patterns to Avoid

Anti-Pattern 1: Unclear Closing Responsibility

antipattern_unclear.go
// Illustrative snippet — not a complete program
// ✗ ANTI-PATTERN: Who closes?
func Process(in chan int, out chan int) {
    for v := range in {
        out <- v * 2
    }
    // Does this close out? Who knows!
}

// ✓ CORRECT: Clear from types and docs
// Transform reads from in and writes to out.
// Does not close either channel.
func Transform(in <-chan int, out chan<- int) {
    for v := range in {
        out <- v * 2
    }
}

Anti-Pattern 2: Missing Context for Blocking Operations

antipattern_no_context.go
// Illustrative snippet — not a complete program
// ✗ ANTI-PATTERN: Can't cancel
func WatchFile(path string) <-chan Change {
    ch := make(chan Change)
    go func() {
        for {
            ch <- waitForChange(path)  // Blocks forever
        }
    }()
    return ch
}

// ✓ CORRECT: Cancellable
func WatchFile(ctx context.Context, path string) <-chan Change {
    ch := make(chan Change)
    go func() {
        defer close(ch)
        for {
            select {
            case <-ctx.Done():
                return
            default: // Not cancelled—proceed
                change, err := waitForChangeWithTimeout(
                    path, time.Second)
                if err == nil {
                    select {
                    case ch <- change:
                    case <-ctx.Done():
                        return
                    }
                }
            }
        }
    }()
    return ch
}

Design Checklist

Before finalizing a concurrent API, verify:

Types
Each parameter uses the most restrictive type possible
Return types are receive-only for producers (<-chan T)
Bidirectional parameters have clear justification
Ownership
Every channel has exactly one owner
Closing responsibility is clear and documented
Multi-producer scenarios use coordinator pattern
Lifecycle
All channels are eventually closed (or documented as long-lived)
defer close() used in producers
Long-running operations accept context.Context
Error Handling
Initialization errors return (<-chan T, error)
Runtime errors use Result struct or separate error channel
Error handling is documented
Documentation
Function purpose is clear
Channel ownership is documented
Blocking behavior is documented
Cancellation mechanism is documented (if applicable)

Complete Design Example

Here’s a well-designed API incorporating all principles:

pipeline.go
// Illustrative snippet — not a complete program
// Package pipeline provides concurrent data
// processing utilities.
package pipeline

import (
    "context"
    "sync"
)

// Result holds a processed value or an error.
type Result struct {
    Value int
    Err   error
}

// Generate produces integers from start to end
// (inclusive). The returned channel closes when
// all values have been sent.
func Generate(start, end int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := start; i <= end; i++ {
            out <- i
        }
    }()
    return out
}

// GenerateWithContext produces integers until
// cancelled or limit reached. Returns immediately;
// production happens in background. The channel
// closes when context is cancelled or limit reached.
func GenerateWithContext(
    ctx context.Context,
    limit int,
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 0; i < limit; i++ {
            select {
            case <-ctx.Done():
                return
            case out <- i:
            }
        }
    }()
    return out
}

// Transform applies f to each value from in.
// Returns a new channel with transformed values.
// Output channel closes when input channel closes.
func Transform(
    in <-chan int,
    f func(int) int,
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range in {
            out <- f(v)
        }
    }()
    return out
}

// Filter returns values from in that satisfy
// predicate. Output closes when input closes.
func Filter(
    in <-chan int,
    predicate func(int) bool,
) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range in {
            if predicate(v) {
                out <- v
            }
        }
    }()
    return out
}

// ProcessParallel applies f to each value using
// n workers. Results may arrive out of order.
// Output closes when all input has been processed.
func ProcessParallel(
    ctx context.Context,
    in <-chan int,
    n int,
    f func(int) Result,
) <-chan Result {
    out := make(chan Result)
    var wg sync.WaitGroup

    for i := 0; i < n; i++ {
        wg.Go(func() {
            for v := range in {
                result := f(v)
                select {
                case <-ctx.Done():
                    return
                case out <- result:
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

// Merge combines multiple input channels into one.
// Output closes when all inputs are exhausted.
func Merge(
    ctx context.Context,
    channels ...<-chan int,
) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Go(func() {
            for v := range ch {
                select {
                case <-ctx.Done():
                    return
                case out <- v:
                }
            }
        })
    }

    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

Usage example:

pipeline_usage.go
// Illustrative snippet — not a complete program
func main() {
    ctx, cancel := context.WithTimeout(
        context.Background(),
        5*time.Second,
    )
    defer cancel()

    // Build pipeline:
    // generate → transform → filter → process
    numbers := Generate(1, 100)
    squared := Transform(numbers, func(n int) int {
        return n * n
    })
    evens := Filter(squared, func(n int) bool {
        return n%2 == 0
    })

    results := ProcessParallel(ctx, evens, 4,
        func(n int) Result {
            time.Sleep(10 * time.Millisecond)
            return Result{Value: n, Err: nil}
        },
    )

    for r := range results {
        if r.Err != nil {
            log.Printf("error: %v", r.Err)
            continue
        }
        fmt.Println(r.Value)
    }
}

Key Takeaways

  1. Use the most restrictive channel type—give each function only the capability it needs (<-chan T for consumers, chan<- T for producers)
  2. Return <-chan T from producers—the output channel pattern encapsulates concurrency and protects internal control
  3. Document what types can’t express—closing responsibility, blocking behavior, and cancellation mechanism
  4. Accept context.Context for anything that might block—finite helpers are optional; infinite producers and I/O operations require it
  5. Verify with the design checklist before shipping—types, ownership, lifecycle, error handling, and documentation

Next: Before moving to Chapter 7, work through the complete runnable example that demonstrates all Chapter 6 concepts in action, then test your understanding with the self-check questions.

Section 6.6 — in one line

Three questions settle most channel APIs: what capability each side needs, who owns the lifecycle, and how cancellation reaches the producer. Because directional types cost nothing at runtime, the answer to the first is almost always to narrow.


Complete Runnable Example

Before moving to Chapter 7, here’s a complete program demonstrating all Chapter 6 concepts:

main.go
package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// =============================================
// PATTERN: Output Channel (Section 6.5)
// Function creates channel internally,
// returns receive-only view
// =============================================

func generateNumbers(
    ctx context.Context,
    max int,
) <-chan int {
    out := make(chan int)

    go func() {
        defer close(out) // Producer owns closing (6.4)
        for i := 1; i <= max; i++ {
            select {
            case <-ctx.Done():
                return // Context cancellation (6.5)
            case out <- i:
            }
        }
    }()

    return out // Caller gets <-chan int (6.2)
}

// =============================================
// PATTERN: Transformer (Section 6.4, Pattern 4)
// Receives from one channel, sends to another,
// owns neither
// =============================================

func squareNumbers(
    in <-chan int,  // can only receive (6.2)
    out chan<- int, // can only send (6.1)
) {
    for n := range in {
        out <- n * n
    }
    // Does NOT close out—caller owns it
}

// =============================================
// PATTERN: Coordinator with Multiple Producers
// (Section 6.4, Pattern 2)
// Coordinator creates channel, workers send,
// coordinator closes after WaitGroup
// =============================================

func fanIn(
    ctx context.Context,
    channels ...<-chan int,
) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    for _, ch := range channels {
        wg.Go(func() {
            for v := range ch {
                select {
                case <-ctx.Done():
                    return
                case out <- v:
                }
            }
        })
    }

    // Separate closer goroutine (see 6.4)
    go func() {
        wg.Wait()
        close(out) // Coordinator closes
    }()

    return out
}

// =============================================
// MAIN: Compose all patterns
// =============================================

func main() {
    ctx, cancel := context.WithTimeout(
        context.Background(),
        2*time.Second,
    )
    defer cancel()

    // Generate two number streams
    stream1 := generateNumbers(ctx, 5)
    stream2 := generateNumbers(ctx, 5)

    // Transform each stream
    squared1 := make(chan int)
    squared2 := make(chan int)

    go func() {
        squareNumbers(stream1, squared1)
        close(squared1) // This goroutine owns closing
    }()

    go func() {
        squareNumbers(stream2, squared2)
        close(squared2) // This goroutine owns closing
    }()

    // Merge streams (coordinator pattern)
    merged := fanIn(ctx, squared1, squared2)

    // Consume all results
    fmt.Println("Squared numbers from both streams:")
    for v := range merged {
        fmt.Printf("  %d\n", v)
    }

    fmt.Println("Done! All channels closed, all goroutines exited.")
}

Run it:

Terminal
$ go run main.go

Verify thread safety:

Terminal
$ go run -race main.go

You should see no race warnings—proper channel design with directional types guides us toward race-free programs.

Expected output (order varies—streams run concurrently):

Output (one real run — order varies)
Squared numbers from both streams:
  1
  1
  4
  9
  4
  16
  9
  16
  25
  25
Done! All channels closed, all goroutines exited.
Concepts Demonstrated
Row
Output channel pattern (6.5)
Send-only parameters (6.1)
Receive-only parameters (6.2)
Transformer pattern (6.4)
Coordinator pattern (6.4)
Context cancellation (6.5)
Proper closing discipline

Chapter 6 Complete: Type-Safe Channel APIs

You now have the tools to design channel-based APIs that are correct by construction:

Chapter 6 Section Summary
Row
6.1
6.2
6.3
6.4
6.5
6.6

Connecting the Concepts

CHAPTER 6 COMPLETE PICTURE

A summary of the chapter as layers. Sections 6.1 and 6.2 restrict capability at the type level, 6.3 draws the line between what types enforce and what they do not, 6.4 assigns ownership, 6.5 packages both into the output channel pattern, and 6.6 turns the result into API guidelines.


Chapter 6 Self-Check

Test your understanding of the concepts covered in this chapter. Click each question to reveal the answer.

1. What’s the difference between func generate() chan int and func generate() <-chan int?

The first returns bidirectional—the caller can send, receive, or close, potentially interfering with the generator. The second returns receive-only—the caller can only consume values, protecting the generator’s control over sending and closing. Always return <-chan T from generators.

2. What operations are permitted on chan<- int? What about <-chan int?

chan<- int: send (✓), close (✓), len/cap (✓), receive (✗), range (✗). <-chan int: receive (✓), range (✓), len/cap (✓), send (✗), close (✗). The key asymmetry: send-only can close (closing is a sender operation); receive-only cannot.

3. A function has signature func process(ch chan int) but only reads from the channel. What’s wrong, and how should it be fixed?

The signature exposes too much capability. With chan int, the function can accidentally send to, close, or otherwise interfere with the channel. Fix: func process(ch <-chan int). The compiler then prevents sends and closes, matching the function’s actual role as a consumer.

4. You see this signature: func fetch(urls []string, results chan<- Response). Who creates the channel? Who closes it? How do you know?

Caller creates and owns the channel. The signature chan<- T as parameter means “I send to a channel you provide.” The function sends but shouldn’t close (though the type permits it). The caller coordinates completion and closes. This is the Passed-In Channel pattern (Section 6.4, Pattern 3). Documentation should clarify closing responsibility.

5. Three workers send to a shared chan<- Result. Worker 1 finishes and calls close(). What happens when Worker 2 tries to send? What happens when Worker 3 calls close()?

Worker 2’s send panics: “panic: send on closed channel”. Worker 3’s close panics: “panic: close of closed channel”. This is why individual producers must NOT close shared channels—only a coordinator who knows when ALL producers are done should close (Section 6.4, Pattern 2).

6. Why can chan<- T close but <-chan T cannot? What principle does this reflect?

Closing is a sender operation—it signals “no more values will be sent.” Only code that sends has the knowledge to make this declaration. Receivers don’t know if other senders exist or when sending will complete. This reflects the sender-closes principle (Section 3.3). The type system enforces it: <-chan T makes closing a compile error.

7. A function returns chan int instead of <-chan int. What bugs does this enable that the stricter return type would prevent?

Returning chan int allows callers to: (a) send values back—creating feedback loops or corrupting the stream, (b) close the channel—potentially while the producer is still sending, causing a panic. With <-chan int, both are compile errors. The producer retains exclusive control over sending and closing.

8. Can you convert <-chan int back to chan int using a type assertion through interface{}? What happens if you try?

No—it causes a runtime panic. interface{}(recvOnly).(chan int) panics with “interface conversion: interface {} is <-chan int, not chan int”. Directional restriction cannot be circumvented through interface conversion, type assertions, or reflection. Once restricted, the restriction is permanent. This is intentional—if you could convert back, type safety would be meaningless.

9. When should a generator function accept context.Context, and why can’t closing alone solve the problem it addresses?

Accept context.Context for infinite or long-running generators. An infinite generator that only closes on completion never completes—so it never closes. If the consumer stops receiving (times out, errors, loses interest), the producer blocks forever on send, leaking the goroutine. Closing doesn’t help because the producer would need to close, but it doesn’t know the consumer stopped. Context enables the producer to detect cancellation via select on ctx.Done() and exit cleanly.

Important caveat: Context cancellation is cooperative—it only takes effect when the goroutine reaches a select statement. If readEvent() takes 5 seconds and you cancel after 1 second, the goroutine still runs for 4 more seconds before the next select iteration detects ctx.Done().

Next: Chapter 7 explores Channel Patterns—pipelines, fan-out/fan-in, worker pools, and more. You’ll apply the directional types and ownership patterns from this chapter to build production-ready concurrent systems.



Exercise 6.1 — Narrow It Until It’s Safe

Your move

Give the close an owner, then let the type say so

This is the chapter in one function. FanIn has both problems it warns about: no one owns the close, and the signature promises the caller more than it should.

ch06/fanin.go
package ch06

import "sync"

// TODO(reader): FanIn merges several input channels into one. Two
// things are wrong with it, and this chapter fixes both.
//
//  1. Every goroutine closes `out` when its own input dries up.
//     With more than one input that is a double close, and the
//     panic you see is a race: usually `send on closed channel`
//     (another worker was mid-send), sometimes `close of closed
//     channel`. §6.4 Pattern 2 says why: no single producer can
//     know it is the last, so the close belongs to a coordinator
//     that waits for all of them.
//
//  2. The signature hands the caller a bidirectional `chan int`, so
//     nothing stops them sending into it or closing it early. §6.5
//     says what to return instead — and the type system will then
//     enforce it for you.
//
// Do not change the parameter type, and do not buffer your way out of
// the panic: a buffer would only postpone it.
func FanIn(inputs []<-chan int) chan int {
	out := make(chan int)
	var wg sync.WaitGroup

	for _, in := range inputs {
		wg.Go(func() {
			for v := range in {
				out <- v
			}
			close(out) // <- your move
		})
	}

	return out
}
ch06/fanin_test.go
package ch06

import (
	"reflect"
	"slices"
	"testing"
	"time"
)

func feed(values ...int) <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for _, v := range values {
			ch <- v
		}
	}()
	return ch
}

func TestFanInReturnsEveryValue(t *testing.T) {
	in := []<-chan int{feed(1, 2, 3), feed(10, 20), feed(100)}

	done := make(chan []int, 1)
	go func() {
		var got []int
		for v := range FanIn(in) {
			got = append(got, v)
		}
		done <- got
	}()

	select {
	case got := <-done:
		slices.Sort(got)
		want := []int{1, 2, 3, 10, 20, 100}
		if !slices.Equal(got, want) {
			t.Fatalf("FanIn gave %v, want %v", got, want)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("FanIn never closed its output channel")
	}
}

// This chapter's own thesis, as a test: a function that hands a stream
// to a caller should return <-chan T, so the caller cannot send into
// it or close it. §6.5.
func TestFanInReturnsAReceiveOnlyChannel(t *testing.T) {
	out := reflect.TypeOf(FanIn).Out(0)

	if out.Kind() != reflect.Chan {
		t.Fatalf("FanIn returns %v, want a channel", out)
	}
	if out.ChanDir() != reflect.RecvDir {
		t.Fatalf("FanIn returns %v; want <-chan int so callers "+
			"cannot send into it or close it. See §6.5.", out)
	}
}

The first test fails immediately, and loudly:

go test ./...
panic: send on closed channel [recovered, repanicked]
goroutine 24 [running]:
corebackend.dev/go-concurrency/ch06.FanIn.func1()
 /path/to/ch06/fanin.go:30
FAIL    corebackend.dev/go-concurrency/ch06

The second test is the more interesting one, because it is this chapter’s own thesis turned into an assertion. Fix the close and leave the return type alone, and it still fails:

after fixing only the close
--- FAIL: TestFanInReturnsAReceiveOnlyChannel (0.00s)
  FanIn returns chan int; want <-chan int so callers
  cannot send into it or close it. See §6.5.
FAIL    corebackend.dev/go-concurrency/ch06

A correct FanIn is safe at runtime and says so in its signature. Those are separate properties, which is why they are separate tests.

Done when: go test -race ./... in code/ch06/ reports ok for both tests, with every value forwarded exactly once. Don’t change the parameter type, and don’t buffer your way out — a buffer only postpones the panic.
Hint, if you want one: §6.4 Pattern 2 answers the first half in three lines, and it needs its own goroutine for the reason “Why a Separate Closer Goroutine?” gives. The second half is one word in the signature.
Where the files are: labs/go-concurrency/code/ch06/. A worked answer sits in solution/fanin.go.txt.

Further reading

Next

You can now say what a channel type promises, where that promise stops, who is allowed to close, and how to hand a stream to a caller in a form they cannot misuse. Every pattern so far has been a single stage — one producer, one consumer, one channel. Chapter 7 composes them: pipelines built from the output channel pattern, fan-out and fan-in across stages, and how cancellation propagates backwards through a chain when the consumer at the end stops listening.