Go by Example

Channel Directions

Restrict channels to send-only or receive-only in function signatures to document and enforce data flow.

When passing a channel to a function, you can constrain its direction: chan<- T is send-only, <-chan T is receive-only. The compiler enforces the constraint - misuse is a compile error, not a runtime panic.

Without direction constraints, any function could accidentally receive from a channel meant only for sending, or send to one meant only for receiving. Here, ping is a pure sender and pong is a relay - it receives from one channel and forwards to another. Restricting each function to only the direction it needs makes the mistake a compile error instead of a silent bug.

package main
 
import "fmt"
 
func ping(pings chan<- string, msg string) {
    pings <- msg
}
 
func pong(pings <-chan string, pongs chan<- string) {
    msg := <-pings
    pongs <- msg
}
 
func main() {
    pings := make(chan string, 1)
    pongs := make(chan string, 1)
 
    ping(pings, "passed message")
    pong(pings, pongs)
    fmt.Println(<-pongs) // "passed message"
}

A bidirectional chan T is assignable to chan<- T or <-chan T without a cast - the compiler inserts the narrowing automatically:

ch := make(chan int)
 
var send chan<- int = ch   // valid: narrowing to send-only
var recv <-chan int = ch   // valid: narrowing to receive-only
 
// send = recv  // compile error: <-chan int is not assignable to chan<- int

A function can also return a directional channel. The return type <-chan int tells callers they may only receive - they cannot accidentally send to or close the internal channel. The body below is a mock of a real producer pattern (streaming events, reading from a database); the important part is the return type signature, not the loop internals.

func generate(done <-chan struct{}) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 0; ; i++ {
            select {
            case out <- i: // in production: emit a db row, event, generated ID, etc.
            case <-done:   // caller signals stop - goroutine exits and closes out
                return
            }
        }
    }()
    return out // returns immediately; goroutine keeps running in background
}
 
func main() {
    done := make(chan struct{})
    nums := generate(done)
 
    fmt.Println(<-nums) // 0
    fmt.Println(<-nums) // 1
    fmt.Println(<-nums) // 2
 
    close(done) // built-in: unblocks any goroutine waiting on <-done, signaling it to stop
}

In production

Directional channel types are documentation enforced by the compiler. A function that accepts chan<- T cannot accidentally receive from it, and a function accepting <-chan T cannot close it (only the sender should close). Use them in public APIs to make data flow explicit and prevent misuse by callers. The discipline also helps readers understand a function's role at a glance: a <-chan parameter means "this is a consumer", a chan<- parameter means "this is a producer".

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free