Right, let’s talk about directional channels. You’ve seen chan int by now, but you might have also seen some weird-looking stuff like chan<- string or <-chan bool and wondered if the Go designers were just messing with you. They weren’t. This is one of those features that seems like a tiny, pedantic detail at first but quickly becomes your best friend for writing clear, robust, and safe concurrent code. It’s basically the type system giving you a free security review.

Think of a regular bidirectional channel (chan T) as a door that swings both ways. Anyone can shove data in or pull data out, and it’s chaos. A directional channel is a door with a sign on it that says either “IN ONLY” (chan<- T) or “OUT ONLY” (<-chan T). This isn’t a new type of channel; it’s a view or a restriction on an existing channel. You create a bidirectional channel and then hand out these restricted views to different parts of your program. Why? To enforce intent and prevent idiocy.

The Two Flavors: Senders and Receivers

Let’s break down the two restrictions.

A send-only channel is typed as chan<- T. The <- is on the left of the chan keyword, pointing into the channel. If you have a variable of this type, you can only send to it. Trying to receive from it is a compile-time error. The compiler will slap your hand before you even run the code.

func feedMe(onlySend chan<- string, message string) {
    onlySend <- message // This is perfectly legal.
    // value := <-onlySend // This is ILLEGAL. Compiler says: "invalid operation: cannot receive from send-only channel onlySend"
}

Conversely, a receive-only channel is typed as <-chan T. The <- is on the left, pointing out of the channel. With this, you can only receive. Trying to send to it is, you guessed it, a compile-time error.

func consume(onlyReceive <-chan string) {
    value := <-onlyReceive // This is what you're here for.
    fmt.Println(value)
    // onlyReceive <- "new message" // ILLEGAL. "invalid operation: cannot send to receive-only channel onlyReceive"
}

This is genius because it moves a whole class of potential concurrency bugs from runtime (where they are nasty, non-deterministic, and hard to debug) to compile time (where they are obvious and annoying, just how we like them).

How to Use This in the Real World

The most common and powerful pattern is to use these when passing channels to functions and when returning channels from functions. It acts as a form of documentation and a contract.

Imagine you’re writing a function that starts a goroutine to produce data. This function should return a channel for the caller to receive that data. It is absolutely none of the caller’s business to send data back into this channel. So, you return a receive-only channel.

// NewProducer starts a goroutine that generates items.
// It returns a <-chan Item, meaning you can only read from it.
// This makes your API intent crystal clear.
func NewProducer() <-chan Item {
    ch := make(chan Item) // creating a bidirectional channel
    go func() {
        defer close(ch) // The producer is responsible for closing.
        for i := 0; i < 5; i++ {
            ch <- Item{ID: i}
        }
    }()
    return ch // This bidirectional channel is automatically converted to <-chan Item for the return.
}

func main() {
    itemChan := NewProducer()
    for item := range itemChan { // Consumer can only range and receive.
        fmt.Printf("Got item %d\n", item.ID)
    }
    // itemChan <- Item{ID: 99} // ILLEGAL. The main function can't send to this receive-only view.
}

Now, let’s look at the opposite. You write a function that should consume data from a channel. You don’t want this function getting any ideas about trying to read from the channel; its only job is to send. So, you take a send-only channel as an argument.

// StartWorker starts a goroutine that processes items.
// It takes a send-only chan<- error because its only job is to report errors back.
// It cannot read from the error channel, which is good because it shouldn't!
func StartWorker(id int, work chan string, resultChan chan<- string, errChan chan<- error) {
    go func() {
        for item := range work {
            if item == "bad" {
                errChan <- fmt.Errorf("worker %d got a bad item", id) // Can only send errors.
            }
            resultChan <- fmt.Sprintf("worker %d processed %s", id, item)
        }
    }()
}

The Conversion Rules (Or, “How to Cheat Safely”)

You can always convert a bidirectional channel to a directional one. This is a widening conversion and is always safe. It’s like taking a Dog and treating it as an Animal.

biChan := make(chan int)
var sendChan chan<- int = biChan // This is allowed.
var receiveChan <-chan int = biChan // This is also allowed.

Going the other way, from a directional channel to a bidirectional one, is not allowed. And thank goodness for that. The whole point is to restrict capabilities, and letting you just cast that restriction away would make the feature useless. The compiler will stop you.

var directional <-chan int = make(<-chan int)
// bi := directional // Compiler Error: "cannot use directional (type <-chan int) as type chan int in assignment"

The one “gotcha” is that this is a compile-time check, not a runtime one. If you have two goroutines with a bidirectional channel and one has a send-only view and the other has a receive-only view, they’re still sharing the same channel. If one goroutine closes the send-only channel, the receive-only goroutine will see it. The restriction is on the variable reference, not the underlying channel data structure.

The #1 Best Practice: Who Closes?

This is where directional channels truly shine and solve a massive point of confusion. The rule is simple: The sender is always responsible for closing the channel.

Why? Because the receiver often doesn’t know when the sender is done. The sender does. Therefore, you should only ever close a send-only channel (chan<- T). In fact, you can only close a send-only or bidirectional channel. Trying to close a receive-only channel is a compile-time error.

func producer(ch chan<- int) {
    defer close(ch) // We are the sender. We are responsible for closing. This is correct.
    // ... send stuff
}

func consumer(ch <-chan int) {
    for range ch {
        // ... receive stuff
    }
    // close(ch) // ILLEGAL. "invalid operation: cannot close receive-only channel ch"
}

By making your function signatures accept and return directional channels, you bake this best practice right into your API design. A function that accepts a chan<- T is taking on the responsibility to send (and likely to close). A function that accepts a <-chan T is saying “I will only read; I promise not to break anything.” It makes your code self-documenting and radically safer. Stop using bidirectional channels everywhere by default. Hand out the most restricted view possible. Your future self, trying to debug a midnight panic, will thank you.