1. Why Go has channel direction
When you start writing concurrent code, the first impulse is: “let all functions take chan T so they can both read and write—more universal.” This is like giving every employee a master key to the office: convenient… until the first weird story about missing cookies and “I didn’t touch your prod at all.”
Channel direction is a way, at the type level, to say: “this function only sends” or “this function only reads.” In Go, this is considered normal practice: less freedom in the API means fewer accidental mistakes. And it’s very much in the spirit of the Go philosophy of “communicate by sharing data, not by sharing memory.”
Channel types and “access rights”
At a human level, a channel is a single object, but at the type level Go distinguishes “access rights” to it. Imagine the channel is a door, and direction is the sign: “entrance only” or “exit only.” It’s the same door, but the sign prevents you from doing something silly like trying to “exit through the entrance.”
In Go there are three forms:
| Channel type | Can send (ch <- v) | Can receive (<-ch) | Can close(ch) |
|---|---|---|---|
|
yes | yes | yes |
| chan<- T (send-only) | yes | no | yes |
| <-chan T (receive-only) | no | yes | no |
Key point: direction is part of the type, not a “channel mode at runtime.” The runtime does not store a “direction flag”; direction is needed by the compiler to forbid incorrect operations ahead of time.
Let’s look at a micro example: the compiler really does protect you.
package main
import "fmt"
func main() {
ch := make(chan int, 1)
var out chan<- int = ch
var in <-chan int = ch
out <- 10
fmt.Println(<-in) // 10
// in <- 20 // compile error: cannot send to receive-only channel
// fmt.Println(<-out) // compile error: cannot receive from send-only channel
}
Note: we created a single channel ch, but we can “hand it out” to different parts of the program with different permissions. And that already looks like a proper contract, not “we take everything and hope nobody hits the red button.”
2. Narrowing permissions in function signatures
In real code, directed channels most often live in function signatures. The idea is simple: if a function is designed to only send, let it accept chan<- T. If it only reads, let it accept <-chan T.
Why does this work well? Because the signature becomes mini-documentation: you see it and immediately understand the function’s role without even reading the body. This is especially nice a couple of weeks later, when you open your code and think: “who wrote this masterpiece, and why does it still work?” Spoiler: you did.
Here’s the basic “producer → consumer” template.
package main
import "fmt"
func produce(out chan<- int) {
out <- 1
out <- 2
close(out)
}
func consume(in <-chan int) {
for v := range in {
fmt.Println("got:", v) // got: 1 / got: 2
}
}
func main() {
ch := make(chan int)
go produce(ch)
consume(ch)
}
Here you can immediately see three things.
- First, produce physically cannot “accidentally” read from the channel.
- Second, consume cannot “accidentally” send a value there and thus break the protocol.
- Third, closing (close(out)) lives on the sender side, which matches the rule “the sender closes.”
One more important point: a bidirectional chan T can be passed where a chan<- T or a <-chan T is expected. This “narrowing of capabilities” happens automatically when assigning/passing an argument. But you can’t “widen rights” back: that’s the protection.
4. API design: return a stream and close the channel
Return a receive-only channel
Sometimes you want an API where you expose a “stream of values,” but external code must not send anything into it and definitely must not close the channel. In such cases, it’s very convenient to return a receive-only channel <-chan T from a function.
Conceptually it’s like a coffee machine: you can get coffee (read from the channel), but you can’t “put your own tea inside” (send into the channel), and you can’t lock the machine with a key (close). The contract is simple and clear.
Example: a function creates a channel, starts a goroutine, and returns <-chan int.
package main
import "fmt"
func numbers(n int) <-chan int {
ch := make(chan int)
go func() {
for i := 1; i <= n; i++ {
ch <- i
}
close(ch)
}()
return ch
}
func main() {
for v := range numbers(3) {
fmt.Println(v) // 1 then 2 then 3
}
}
Why is this a great style?
- Because from the outside it’s impossible to break the protocol: you have no send access.
- And this kind of API reads like a promise: “I return a stream of values; read it to the end.”
By the way, this pattern regularly shows up in examples and discussions of Go approaches to data streams: filters/pipelines often do exactly this—return a <-chan as the result.
Who can close, and why it matters
Most of the most dramatic episodes in “Concurrency Santa Barbara” start with close, because close is not “remove a channel,” it’s “announce that there will be no more sends.” If the “wrong” party closes it, the sender may try to send one more value—and get a panic. That’s not a hang; that’s an explosion with smoke.
Directed types help here too: if a function accepts <-chan T, it physically cannot close the channel—the compiler will forbid it. That’s nice because closing is the sender’s responsibility, and types reinforce the right architecture.
Let’s look at a small example where the compiler doesn’t let you break the agreement.
package main
func consume(in <-chan int) {
// close(in) // compile error: cannot close receive-only channel
_ = in
}
func main() {}
And here’s a sender that accepts chan<- int— it can (and should, if it’s a finite stream) close the channel:
package main
func produce(out chan<- int) {
out <- 1
close(out)
}
func main() {}
The result is a neat discipline: “readers read,” “writers write and close.” And there’s less chance that someone “helped” and closed the channel early.
5. Mini refactor: a task stream and stats counting
So this doesn’t remain an abstraction about “channels with numbers,” let’s tie directed channels to our training app. In the course, we’ve already modeled tasks (task/todo), stored them, printed them in CLI/HTTP, did sorting, and worked with errors. Now imagine a small internal need: quickly compute task statistics without breaking the rest of the code.
The idea is: we have a function that streams tasks into a channel (producer), and a function that reads that stream and counts how many tasks are completed (consumer). No select, no timeouts—just a clean channel + range.
Let’s start with the task model (simplified):
package main
type Task struct {
ID int
Text string
Done bool
}
Now let’s make the “producer”: it takes a list of tasks and sends them into a channel. Pay attention to the signature: out chan<- Task.
package main
func streamTasks(tasks []Task, out chan<- Task) {
for _, t := range tasks {
out <- t
}
close(out)
}
And the “consumer”: it only reads from <-chan Task and counts done.
package main
func countDone(in <-chan Task) int {
done := 0
for t := range in {
if t.Done {
done++
}
}
return done
}
Let’s glue it together in main to see the big picture:
package main
import "fmt"
func main() {
tasks := []Task{
{ID: 1, Text: "read Go spec", Done: false},
{ID: 2, Text: "fix bug", Done: true},
{ID: 3, Text: "drink tea", Done: true},
}
ch := make(chan Task)
go streamTasks(tasks, ch)
done := countDone(ch)
fmt.Println("done:", done) // done: 2
}
From a protocol perspective, everything is clean here: the sender (in a goroutine) sends all values and closes the channel; the receiver reads via range and finishes automatically. And most important for our topic: the signatures lock in the roles. Even if in a month you move streamTasks and countDone into different files/packages, the types already show who does what.
For clarity, here’s a small diagram (not “magic,” just a visualization of what we already wrote):
flowchart LR
A["streamTasks(tasks, out chan<- Task)"] -->|out <- Task| CH[(chan Task)]
CH -->|range in <-chan Task| B["countDone(in <-chan Task)"]
A -->|"close(out)"| CH
If you’re thinking now: “why not have countDone accept chan Task, it works too?”—yes, it will work. But chan Task says “I can both send and receive,” while <-chan Task says “I only receive.” And that “only” is our safety belt.
6. Typical mistakes when using chan<- and <-chan
Mistake No. 1: using chan T everywhere “because it’s simpler.”
At the beginning it feels convenient: less thinking, fewer symbols. But you pay for it by losing compiler checks of the protocol. A week later, a monster function appears in the codebase that reads, writes, closes, and “does a little logging,” and then you catch a hang and start suspecting quantum mechanics.
Mistake No. 2: trying to “widen rights” back if you have <-chan T.
Sometimes beginners get a <-chan T from a function and then want to “just send a little value into it.” You can’t, and that’s correct: if the API returned a receive-only channel, sending is not your responsibility. If you really want to send, the function’s contract must be different—not “let’s trick the types.”
Mistake No. 3: closing the channel somewhere other than where sends are finished.
Even with directed types you can still make a logical mistake: for example, close out before the send loop is finished (or close in the middle by accident). The result is unpleasant: the next send will trigger a panic. The cure is discipline: close(out) must be placed where you know for sure there will be no more sends (usually after the loop).
Mistake No. 4: mixing different meanings in one channel and trying to “steer” the protocol via values.
When a stream appears, there’s a temptation: “let’s send Task{ID:0} as an end marker.” That’s bad because you start conflicting with zero values and make reading harder. Go channels already have a built-in completion signal: close + range + ok. Use that instead of inventing “secret handshakes.”
Mistake No. 5: thinking directed channels are about performance.
No, they don’t speed up your program and don’t make channels “faster.” Direction is about API design and reducing mistakes. It’s like a seat belt: it doesn’t make the car faster, but it makes the ride less dramatic.
GO TO FULL VERSION