Skip to content
intermediate Phase 9 · Channels, Select, and Channel Patterns

Directional Channels and Iteration

Restrict APIs with directional channel types and disable select cases with nil.

35m
0 problems
Topic Progress 0%

The Signature That Promised More Than It Meant

A pipeline stage is written as func square(in chan int, out chan int). The signature says callers may send into in, receive from in, send into out, receive from out — everything. Nothing stops a caller from closing in mid-stream, squaring nothing, or from receiving out of out and stealing results meant for the next stage. The bug is not a runtime condition you can test for; the type simply does not record who is allowed to do what.

Directional channel types fix this at compile time:

func square(in <-chan int, out chan<- int) {
	for n := range in {
		out <- n * n
	}
}

func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func main() {
	square(generate(2, 3, 4), results) // last arg must be chan int or chan<- int
}

The arrow now sits on one side of the type:

Type Readable? Writable? Meaning
chan int yes yes full-duplex handle
chan<- int no yes write-only endpoint (producer side)
<-chan int yes no read-only endpoint (consumer side)

<-chan int puts the arrow before the type — receive-shaped: data comes out toward you. chan<- int puts it after — send-shaped: data goes in through you. In square, in is receive-only because the stage's job is to read; out is send-only because its job is to write. A caller looking at the signature can now answer both ownership questions without reading the body: who closes what, and where do results flow.

The constraint that makes this practical: a bidirectional channel converts to a directional one, never the reverse. var c chan int; var s chan<- int = c compiles; var c2 chan int = s does not. Widening to a restricted view is safe — you promised less than you have — while narrowing would hand out permissions the original holder never granted. Inside the function, you also cannot undo the restriction: square cannot smuggle in back out as a chan int for someone else to send on.

The safety net people notice last but appreciate most: close requires a bidirectional or send-only channel. You can close a chan<- int — the producer closes its own output, exactly as every pipeline example has done. You cannot close <-chan int, so a consumer that receives from in cannot terminate the producer's stream by fiat. The compile error enforces the ownership rule from Closing Channels instead of trusting review discipline.

Pipelines Read Top to Bottom When the Types Are Honest

Three stages, each a plain function, composed by calling them the way you would compose pure functions — except every stage runs concurrently because each owns a goroutine:

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 sum(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		total := 0
		for n := range in {
			total += n
		}
		out <- total
	}()
	return out
}

func main() {
	for v := range sum(square(generate(2, 3, 4))) {
		fmt.Println(v) // 29
	}
}

Read the signatures alone and the program's shape is complete: generate only produces, square reads then produces, sum reads and emits one final answer. Each stage closes its own output when its input range ends, so termination flows backward through the chain automatically — generate finishes and closes, square's range ends and closes, sum follows. One range in main drains the whole pipeline. No shared state, no WaitGroup, no explicit handshaking between stages; the directional types plus range and close carry all of it.

This composition also exposes the cost model that decides when pipelines are the right shape. Every stage is a goroutine and an unbuffered channel, so backpressure propagates: sum blocks on its final send until main receives, which blocks square, which blocks generate. Stages advance in lockstep — throughput is set by the slowest stage, and nothing queues unboundedly. For CPU-shaped transformations of large streams that is exactly the desired behavior, and it is the design that Fan-Out and Fan-In later parallelizes by giving one stage multiple workers.

Where the pattern bends: stateful stages (sum above) cannot be duplicated naively — two sums would emit two partial totals nobody merges. And error propagation does not exist yet — a stage that fails has no channel to report on, so production pipelines wrap stages in errgroup or carry a result struct instead of a bare int. Directional types get the architecture right; error and cancellation channels are the layers built on top in phase 480.

nil as a Runtime Switch for Optional Cases

Directional types are compile-time ownership. The same channel variables, reassignable to nil at runtime, give select a switch — a case that can be turned off while the program runs.

func merge(primary, backup <-chan int, done <-chan struct{}) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		var secondary <-chan int = backup
		for {
			select {
			case v := <-primary:
				out <- v
			case v := <-secondary:
				out <- v
			case <-done:
				return
			}
		}
	}()
	return out
}

While secondary holds the backup channel, both inputs compete. If backup fails and the goroutine assigns secondary = nil, that case becomes permanently unready — select behaves as though the line were deleted — without restructuring the loop or adding an if useBackup guard around every receive. The type of secondary must be receive-only (<-chan int) precisely so that nil-assignment and channel-assignment are the only operations ever applied to it; the pattern and the directional type reinforce each other.

Three rules keep the trick from becoming a footgun:

  • A select whose every case is nil with no default blocks forever. Disabling branches is only safe while at least one live path remains. The merge loop above keeps primary and done — turn those off too and the goroutine parks permanently, the leak class from Goroutine Leaks.
  • Readability cost is real. secondary starts as an optional input; if a reader must trace assignments across a hundred lines to learn which cases are live, a plain if is kinder. Reserve nil-switching for selects where branches genuinely change at runtime — failover inputs, pause/resume on a control channel, optional replication streams.
  • nil means disabled, closed means done — do not blur them. Assigning nil to a work channel says stop listening, this source is off; closing says the stream has ended and remaining values still count. The first is a mutable condition, the second a permanent fact on the channel itself.

The larger design lesson: a channel's type encodes a permanent permission (send-only, receive-only), while a channel variable's value encodes a temporary state (live, nil, closed — three states select already understands). API design with directional types uses the first; control logic inside a goroutine uses the second. Keeping both visible in the signature — <-chan parameters, a separate done <-chan struct{} — is what makes concurrent functions readable at a glance, and it is the discipline every pattern in the next phase (worker pools, fan-out, errgroup) inherits.

Quiz

1. A function receives `in <-chan int` and a caller passes a plain `chan int`. What happens?

Question 1 options

2. Inside `func consume(in <-chan int)`, the code calls `close(in)`. What is the result?

Question 2 options

3. A select has cases on `primary`, `secondary`, and `done`, and the goroutine sets `secondary = nil` at runtime. What is the effect?

Question 3 options

4. What does a pipeline of three stages, connected by unbuffered channels, guarantee about throughput?

Question 4 options

Flashcards

Question

How do you write a parameter that a function may only send on, and what does receiving-from-it do to the contract?

Answer

`func produce(out chan<- int)` — the arrow after `chan` marks it send-only. The function may send and close but never receive, which documents that it is the producer endpoint; callers passing a plain `chan int` convert implicitly, while the function cannot hand the channel back as bidirectional for anyone to misuse.

Question

Which channel conversion is legal and which is not, and why does the rule make sense?

Answer

Bidirectional → directional always compiles (`chan int` to `chan<- int` or `<-chan int`); directional → bidirectional never does. You can always promise fewer permissions than you hold, but granting yourself permissions the type withheld would defeat the restriction — the type system only narrows guarantees, it never widens them.

Question

In a three-stage pipeline, what makes termination propagate without a WaitGroup?

Answer

Each stage closes its own output with `defer close(out)` as soon as its input range ends, and each downstream stage is ranging over that output. When the source finishes, closes, and stops, the wave runs backward — every stage's range ends, every stage closes its output, and the final consumer's range terminates. Close plus range, on directional signatures, replaces all lifetime bookkeeping.

Question

When is assigning nil to a channel variable the right design, and what is its main hazard?

Answer

It is right for optional select branches that toggle at runtime — disabling a failed backup input or a paused source without restructuring the loop. The hazard: if every case ends up nil (or closed-waiting) with no default, select blocks forever, so nil-switching is only safe while at least one live case remains reachable.

Question

What is the difference between setting a channel variable to nil and closing the channel?

Answer

Nil-assignment changes the variable — other holders of the same channel are unaffected, and select cases watching it simply stop being ready. Close changes the channel itself — every receiver, current and future, sees instant zero-value receives with ok=false, and no further sends are legal. Nil is a per-variable switch; close is a permanent broadcast on the shared object.

Revision Notes

Key Takeaways

  • 1. Directional types record ownership in the signature: receive-only inputs, send-only outputs, and close legal only from the sending side
  • 2. Widening (chan T → directional) is implicit and safe; the reverse never compiles, so permissions cannot be self-granted
  • 3. A pipeline built from directional stages terminates without WaitGroup because each stage closes its output when its input range ends
  • 4. Unbuffered stages propagate backpressure through the whole chain — the slowest stage sets the pace and nothing queues unboundedly
  • 5. nil assignment is a runtime switch for select cases; closed is a permanent channel state — two different controls, never interchangeable

Interview Tips

  • • "Why would you use `<-chan int` instead of `chan int` in a parameter?" — ownership documentation plus enforcement: the callee cannot send or close, so review and compiler both confirm who controls the stream
  • • "Draw a three-stage pipeline and explain how it shuts down" — write the signatures live (receive-only in, send-only out), then trace close+range propagation from source to final consumer
  • • "Can you close a receive-only channel? What about send-only?" — receive-only is a compile error; send-only closes fine (producer closes its output); follow with who closes in a multi-producer setup
  • • "How would you make a select case optional at runtime?" — nil-assignment pattern, then state the block-forever hazard if all cases go dark

Cheat Sheet

Directional Channels and Iteration

Type permissions:

Type Send Receive Close Arrow position
chan T yes yes yes none
chan<- T yes no yes after chan
<-chan T no yes no before chan

Conversion rule: chan T → directional: implicit, always legal. Directional → chan T: never compiles. No conversion between chan<- and <-chan.

Pipeline signature pattern:

func stage(in <-chan T, out chan<- U) {
	defer close(out)
	for v := range in {
		out <- transform(v)
	}
}

// each stage owns one goroutine; termination flows via close+range

nil switch: reassign a select case's channel variable to nil to disable it at runtime; keep one live case (or default) or the goroutine parks forever.

Continue Learning

Next Topics

Related Reading