How to Use Directional Channels (chan<- and <-chan) in Go

Use chan<- T for send-only and <-chan T for receive-only channels to restrict data flow direction in Go.

One-way streets for data

You are building a web scraper. One goroutine fetches URLs, another parses HTML, a third saves results. You connect them with channels. At first, you just use chan string. It works. Then you refactor the parser. You accidentally add a receive operation inside the parser, expecting feedback from the saver. The parser blocks waiting for data that never arrives. The pipeline stalls. The bug is subtle and hard to trace.

Directional channels prevent this. If the parser only receives, the compiler rejects the accidental send. The error appears before you run the code. Directional channels turn runtime deadlocks into compile-time errors. They document intent and lock down permissions.

The concept: narrowing permissions

A directional channel is a bidirectional channel viewed through a restricted lens. The underlying channel is always bidirectional. Directionality is a property of the variable holding the reference, not the channel itself.

Think of a one-way street. The road has lanes for traffic in both directions, but a ramp only lets you enter. You can drive onto the highway, but you cannot drive off it from the ramp. A send-only channel is that ramp. You can send data, but you cannot receive. A receive-only channel is the off-ramp. You can receive data, but you cannot send.

The compiler enforces these rules. If you try to drive the wrong way, the program won't start. The arrow in the type syntax points to the direction of data flow relative to the variable. chan<- int means data flows into the channel. <-chan int means data flows out of the channel.

You can narrow permissions, never widen them. A bidirectional channel can be assigned to a send-only or receive-only variable. A send-only channel cannot be assigned back to a bidirectional variable. The compiler prevents you from gaining permissions you don't have.

Minimal example

Here's the core mechanic: you create a bidirectional channel, then assign it to a variable with a restricted type to narrow the permissions.

// This example demonstrates creating directional views from a bidirectional channel.
package main

func main() {
	// make always returns a bidirectional channel.
	// You cannot create a directional channel directly with make.
	fullChannel := make(chan int)

	// Assign to a send-only variable.
	// The compiler now forbids receiving from sendOnly.
	// Data flows into sendOnly.
	var sendOnly chan<- int = fullChannel
	sendOnly <- 10

	// Assign to a receive-only variable.
	// The compiler now forbids sending to recvOnly.
	// Data flows out of recvOnly.
	var recvOnly <-chan int = fullChannel
	_ = <-recvOnly
}

The variable fullChannel can both send and receive. sendOnly is a restricted view. You can send to sendOnly, but any attempt to receive triggers a compile error. recvOnly is the opposite. You can receive, but sending is rejected. All three variables point to the same underlying channel. The buffer is shared. The restrictions exist only in the type system.

Directional channels cost nothing at runtime. They pay off every time the compiler saves you from a deadlock.

The narrowing mechanism

You cannot call make with a directional type. The compiler rejects this with invalid operation: make(chan<- int) (make of send-only channel). Channels are always bidirectional at creation. Directionality emerges at assignment.

This design supports flexible pipelines. You can create a channel in main, pass a send-only view to a producer, and pass a receive-only view to a consumer. The producer cannot read from the channel. The consumer cannot write to it. The compiler guarantees these invariants.

If you try to widen permissions, the compiler stops you. Assigning a send-only channel to a bidirectional variable fails with cannot use sendOnly (variable of type chan<- int) as chan int value in assignment. The type system treats directional channels as subtypes of bidirectional channels. You can go from general to specific, but never from specific to general.

This behavior mirrors interface satisfaction. A directional channel satisfies a bidirectional interface only if the assignment narrows the type. The rule is consistent: permissions can only be reduced, never expanded.

Realistic example

Here's how directional channels appear in real code: function signatures that document intent and prevent accidental misuse by callers.

// This producer function uses directional channels to clarify its role.
// It reads from a cancellation signal and writes results to an output channel.
func producer(done <-chan struct{}, out chan<- string) {
	// done is receive-only.
	// The function waits for a signal but never sends one.
	// out is send-only.
	// The function pushes data but never reads back.
	for i := 0; i < 5; i++ {
		select {
		case <-done:
			// Exit immediately if cancellation arrives.
			return
		case out <- fmt.Sprintf("result-%d", i):
			// Send result.
			// If the receiver is slow, this blocks until space opens.
		}
	}
}

The signature tells the reader exactly what the producer does. It reads from done and writes to out. It cannot send on done. It cannot read from out. This prevents bugs where a function accidentally blocks waiting for its own output or tries to modify a signal it should only observe.

You'll see <-chan struct{} everywhere for cancellation. context.Context is the standard way to handle cancellation, and its methods return receive-only channels. The convention is strict: context is the first parameter, named ctx, and functions never send on the context's done channel. If you see a function taking <-chan struct{}, it's almost always a cancellation signal. The empty struct takes zero memory, making it the efficient choice for signals that carry no data.

Narrow the type at the boundary. Keep the full channel internal.

Closing and state

Closing a channel is a write operation. You can close a send-only channel, but you cannot close a receive-only channel. The compiler rejects the latter with cannot close receive-only channel. This makes sense. Closing modifies the channel's state. If you only have read access, you shouldn't be able to change the state.

The sender closes the channel. This is the rule. The receiver checks for closure. If the receiver closes the channel, the sender might panic or block. Always close from the side that sends.

// This example shows closing a send-only channel.
package main

func main() {
	// Create a bidirectional channel.
	c := make(chan int)

	// Narrow to send-only.
	var sender chan<- int = c

	// Send a value.
	sender <- 42

	// Close the channel.
	// Closing is allowed on send-only channels.
	close(sender)

	// The channel is now closed.
	// Receivers will get zero values and false from the comma-ok idiom.
}

The receiver uses the comma-ok idiom to detect closure. val, ok := <-recvOnly. If ok is false, the channel is closed and drained. The receiver loops until ok is false. The sender closes once all data is sent. This pattern is universal in Go. Break it and you'll leak goroutines or panic.

The sender closes. The receiver waits. Break this rule and you'll leak goroutines.

Pitfalls and errors

Directional channels introduce a few specific pitfalls. Knowing the errors helps you fix mistakes quickly.

You cannot create a directional channel with make. The error is invalid operation: make(chan<- int) (make of send-only channel). Use make(chan int) and assign to a directional variable.

You cannot close a receive-only channel. The error is cannot close receive-only channel. Close the channel from a variable that has send permissions.

You cannot receive from a send-only channel. The error is receive from send-only channel. Check your variable types. If you need to receive, you need a bidirectional or receive-only variable.

You cannot send to a receive-only channel. The error is send to receive-only channel. Same fix. Verify the direction matches your intent.

Nil channels behave the same regardless of directionality. Operations on a nil channel block forever. select handles nil channels correctly by ignoring them. If all cases in a select are nil, the select blocks. This is useful for disabling cases dynamically. Directional nil channels follow the same rules.

Directional channels don't add runtime overhead. The compiler removes the checks. The generated code is identical to bidirectional channels. The safety comes for free.

Trust the compiler. If it rejects the direction, your logic is wrong.

Decision: when to use directional channels

Use a send-only channel (chan<- T) when a function or goroutine only writes data and should never read back. This prevents deadlocks where a writer accidentally waits for its own output.

Use a receive-only channel (<-chan T) when a function or goroutine only consumes data and should never modify the stream. This protects the pipeline from accidental writes or premature closure.

Use a bidirectional channel (chan T) when you need to create the channel with make or when a single variable must both send and receive, such as in a simple request-response loop.

Use a directional variable assignment to narrow a bidirectional channel when passing it to a helper function, locking down permissions at the call site.

Use chan<- T for output parameters in function signatures to signal that the function writes results. Use <-chan T for input parameters to signal that the function reads data. This convention makes APIs self-documenting.

Where to go next