Channel direction in function signatures

Use <-chan T for receive-only and chan<- T for send-only channels in Go function signatures to enforce unidirectional data flow.

The pipe that only flows one way

You are building a log processor. One function reads lines from a file and pushes them into a channel. Another function takes those lines, formats them, and writes them to standard output. You pass the channel between them. Everything works until you refactor the formatter and accidentally add a read operation to the writer function. The program deadlocks at runtime. You spend twenty minutes tracing goroutine stacks before realizing the writer is waiting for data that will never arrive.

Go prevents this exact scenario by letting you lock channel direction at the function boundary. You declare whether a function can only send, only receive, or do both. The compiler enforces the restriction before the program ever runs. You get a compile-time guarantee that data flows in the direction you intended.

Lock the door. Do not leave it ajar.

Why restrict a channel at all

Channels are bidirectional by default. A chan int can send and receive integers. That flexibility is useful when you are writing a single function that both produces and consumes data. It becomes dangerous when you pass the channel across function boundaries. Every function that holds a bidirectional channel has the power to break the data flow. A consumer might accidentally try to send. A producer might accidentally try to receive. The type system gives you no warning until runtime.

Directional channels narrow that power. They are a compile-time contract. When you declare a parameter as <-chan int, you are telling the compiler and every future reader that this function will only pull values out. When you declare chan<- string, you are saying this function will only push values in. The underlying runtime object is still the same channel. The restriction lives entirely in the type checker. You get the safety of a read-only or write-only pointer, but for concurrent communication.

Think of it like a physical pipe. Some pipes are input-only. Some are output-only. Go lets you declare which end you are holding. The compiler refuses to let you turn the pipe around.

The syntax in practice

The arrow in the type signature points toward the action the function is allowed to perform. <-chan T means receive-only. The arrow points toward chan, indicating data flows into the variable from the channel. chan<- T means send-only. The arrow points away, indicating data flows out of the variable into the channel.

// ReceiveOnlyConsumer reads integers from a channel and prints them.
func ReceiveOnlyConsumer(ch <-chan int) {
    // Range automatically blocks until the channel closes.
    // We can only read here. Sending would fail at compile time.
    for n := range ch {
        fmt.Println(n)
    }
}

// SendOnlyProducer writes a single integer into a channel.
func SendOnlyProducer(ch chan<- int) {
    // The arrow points away from chan. We can only push values.
    // Reading from ch here would be rejected by the compiler.
    ch <- 42
}

// BidirectionalHandler can both read and write.
func BidirectionalHandler(ch chan int) {
    // Full access. Use this sparingly.
    // It signals that the function participates in two-way communication.
    ch <- 1
    n := <-ch
    fmt.Println("got:", n)
}

The syntax looks small, but it changes how you reason about concurrency. You stop asking what a function might do with a channel. You know exactly what it is allowed to do. The type signature becomes documentation that the compiler enforces.

What the compiler actually checks

Go treats directional channels as subtypes of bidirectional channels. A chan int can be passed to a function expecting <-chan int or chan<- int. The compiler narrows the type automatically. You cannot do the reverse. A receive-only channel cannot be assigned to a bidirectional variable. The compiler rejects the assignment with cannot use ch (variable of type <-chan int) as chan int value in argument.

This narrowing is purely static. At runtime, the channel object retains its original capabilities. If you pass a bidirectional channel to a receive-only parameter, the function still cannot send. The restriction is baked into the function's type signature. The compiler strips the send capability from the local variable scope.

You can also convert a bidirectional channel to a directional one explicitly using a type conversion. ch := make(chan int) becomes receiveOnly := (<-chan int)(ch). This is rarely necessary in practice because implicit narrowing handles most cases. Explicit conversions appear mostly in generic code or when you need to satisfy a specific interface method signature.

The compiler also checks channel operations inside the function body. If you write ch <- 5 inside a function that accepts <-chan int, the build fails immediately. The error message is direct: invalid operation: cannot send to receive-only channel ch. You never reach runtime with a broken data flow.

The compiler is your concurrency cop. Let it do the heavy lifting.

Real code: a worker pool

Directional channels shine in pipeline architectures. You chain functions together where each stage transforms data and passes it forward. The producer sends to the next stage. The consumer receives from the previous stage. No stage needs to read and write simultaneously. Locking the direction prevents accidental feedback loops and makes the data flow obvious.

// Fetcher reads URLs from a source and sends them to the worker pool.
func Fetcher(urls []string, out chan<- string) {
    // We only push URLs forward. We never try to read results here.
    for _, u := range urls {
        out <- u
    }
    // Closing the channel signals downstream stages that work is done.
    close(out)
}

// Processor receives URLs, simulates work, and sends results forward.
func Processor(in <-chan string, out chan<- string) {
    // We only pull URLs and push results.
    // The type signature guarantees we cannot accidentally block on out.
    for url := range in {
        // Simulate network or CPU work
        result := url + "?processed=true"
        out <- result
    }
    // Close the output channel when the input is exhausted.
    close(out)
}

// Collector receives final results and prints them.
func Collector(in <-chan string) {
    // Terminal stage. Only reads. Never writes.
    for result := range in {
        fmt.Println("Done:", result)
    }
}

The data flows left to right. Fetcher pushes into Processor. Processor pulls from Fetcher and pushes into Collector. Collector pulls from Processor. Each function signature declares its role. You can read the function list and understand the entire pipeline without tracing variable assignments.

This pattern scales. Add a validation stage between fetch and process. Add a retry stage between process and collect. Each new stage only needs to know its input direction and output direction. The compiler guarantees the chain stays intact.

Data flows one way. Complexity stays out.

Where things go wrong

Directional channels remove entire classes of bugs, but they introduce a few new traps. The most common mistake is trying to close a receive-only channel. Closing is a send-side operation. If you write close(ch) inside a function that accepts <-chan int, the compiler rejects it with invalid operation: cannot close receive-only channel ch. You must close channels on the sender side. The convention is strict: the goroutine that produces data closes the channel. The goroutine that consumes data never closes it.

Another trap is mixing up the arrow direction. Developers often read <-chan T as "send to channel" because the arrow looks like it points left. Remember that the arrow points toward the chan keyword, not toward the data. <-chan means you receive from the channel. chan<- means you send to the channel. Draw it on paper if you need to. The compiler will catch the mistake anyway, but understanding the visual cue saves debugging time.

You will also run into issues when trying to pass a directional channel to a function that expects a bidirectional one. The compiler complains with cannot use ch (variable of type chan<- string) as chan string value in argument. This is correct behavior. You cannot widen a narrowed type. If a function truly needs bidirectional access, you must pass the original bidirectional channel from the caller. The caller decides how much access to grant.

Convention aside: Go functions that participate in long-running operations should accept context.Context as the first parameter, conventionally named ctx. When you combine contexts with directional channels, pass the context first, then the channel. func Worker(ctx context.Context, in <-chan Job, out chan<- Result). The context handles cancellation. The channel handles data flow. Keep their responsibilities separate.

Close the sender. Never the receiver.

Picking the right signature

Use a bidirectional channel when a single function must both produce and consume data, such as a request-response handler or a bidirectional proxy. Use a send-only channel when a function is a pure producer that pushes work forward and never needs to read results. Use a receive-only channel when a function is a pure consumer that pulls work, processes it, and discards or forwards the result. Use a bidirectional channel as the underlying type in main or at the top of your pipeline, then narrow it as you pass it to specialized functions.

Restrict the type. Trust the compiler.

Where to go next