AlgoMaster Logo

Channel Directions

Medium Priority20 min readUpdated June 6, 2026

A channel value in Go can be tagged with a direction. The type chan<- T says "you can only send into this channel" and <-chan T says "you can only receive from it". Plain chan T lets you do both. Direction-restricted types are a compile-time tool that lets a function signature spell out exactly what it intends to do with the channel, and the compiler enforces it.

The Two Direction-Restricted Types

A standard channel declared as chan int is bidirectional. You can send into it with ch <- 7, you can receive from it with v := <-ch, and you can close it with close(ch). Most of the channels you've seen up to this point in the course have been bidirectional.

Go also has two restricted forms that share the same arrow symbol but place it differently:

TypeRead AsSend Allowed?Receive Allowed?Close Allowed?
chan TBidirectionalYesYesYes
chan<- TSend-onlyYesNoYes
<-chan TReceive-onlyNoYesNo

The arrow points "into" the channel for sends and "out of" the channel for receives, and the position of the arrow in the type tells you which operation is allowed. Think of chan<- as "data flows in" and <-chan as "data flows out". The arrow is always next to what you can do.

Here's a runnable program that declares variables of each type and shows what each one is allowed to do:

stock is bidirectional and can do everything. sender is a send-only handle to cart, which is itself bidirectional. receiver is a receive-only handle to that same cart. Both sender and receiver are aliases for the same underlying channel, but the type of the variable controls what you're allowed to do through that variable.

This is the entire feature. Once you understand that the type is a permission slip, the rest of the lesson is about why you'd bother and the patterns that fall out.

Why Directions Exist

A channel that can flow both ways is convenient inside the function that creates it. The same channel is awkward once you start passing it to helper functions. A producer function only sends. A consumer function only receives. If the producer accidentally tries to receive (or close after another writer has already closed), you get a runtime bug. Direction restrictions move the check from runtime to the compiler.

Consider a small order-processing program. The intent of each function is clear from English, but the types don't enforce it:

Both produce and consume take a bidirectional chan string. The function names say one only sends and the other only receives, but the compiler doesn't know that. If someone refactoring consume accidentally wrote orders <- "extra" inside the function, the compiler would happily accept it, and the program would deadlock or panic at runtime when the closed channel got a second close.

Now the same program with direction-restricted parameters:

The behavior is identical. The difference is that produce now declares it can only send, and consume declares it can only receive. If anyone refactoring tries to receive from orders inside produce, the code won't compile. The bug is caught before the program runs.

There's a second benefit that's easy to underrate: signatures become self-documenting. When you read func processPayment(in <-chan Order, out chan<- Receipt), you know immediately that in is where orders come from and out is where receipts go. You don't have to read the function body to figure out the data flow.

Implicit Conversion: Bidirectional to Restricted

You almost never make a send-only or receive-only channel directly. You create a bidirectional channel with make(chan T) and then pass it to functions that take a restricted type. Go converts the bidirectional channel to a restricted view automatically when you pass it as an argument or assign it to a typed variable.

The conversion goes in one direction only: bidirectional can narrow to send-only or receive-only, but not the reverse. Once you have a <-chan T, you can't widen it back to chan T.

Inside main, ch has type chan int. When passed to sendOnly, it gets converted on the fly to chan<- int. When passed to receiveOnly, it gets converted to <-chan int. The original ch in main is unaffected; the conversion creates a new typed view, not a new channel.

The conversion can also happen with explicit variable declarations:

sendSide and receiveSide are different variables, with different static types, but they refer to the same underlying channel. A value sent through sendSide is received through receiveSide.

The one-way nature of the conversion has a real reason behind it. If you could widen a <-chan T back to a chan T, the restriction would be meaningless. A function that promised to only receive could accept a receive-only channel, then convert it back to bidirectional and start sending. The compiler stops that.

You can chain conversions too. A function that takes chan<- int can pass that same parameter to another function that also takes chan<- int. You just can't pass it to a function that takes chan int or <-chan int.

main holds a chan int. It passes that to forward, which receives a chan<- int. Then forward passes its already-restricted parameter to publish, which also accepts chan<- int. The restriction is preserved through every hop.

Compiler Errors When You Misuse Directions

The whole point of restricted channel types is to make wrong code refuse to compile. The messages are short and clear once you've seen them once. Three patterns come up often enough to be worth knowing.

The first is sending into a receive-only channel:

Compiler output:

The error names the variable, names its type, and tells you what went wrong. The fix is obvious: either change the parameter to chan<- int, or stop trying to send.

The second is receiving from a send-only channel:

Compiler output:

Same shape of message. The compiler points at the line, names the operation, and names the variable's actual type.

The third is closing a receive-only channel. Only code that holds a non-receive-only view of the channel can close it.

Compiler output:

That last rule is the most useful of the three in practice. It enforces the convention that only the sender side closes a channel. If a function takes <-chan T, it's announcing "I only receive." The compiler then physically prevents it from closing the channel, which prevents the entire class of bugs where a consumer closes a channel that a producer is still trying to write to (which causes a panic on the next send).

The reverse direction does work: a chan<- T parameter can close the channel, because the holder of a send-only view is by definition on the producer side.

produce has a send-only view of the channel and closes it after sending the last value. That's legal, and it lets the for v := range ch loop in main exit cleanly when the channel is drained. The point here is the close: a send-only handle can close, a receive-only handle cannot.

Reading Function Signatures

Once you know what each arrow position means, channel-heavy code becomes much faster to read. The signature alone tells you the data flow without diving into the body.

Take this signature from a hypothetical inventory pipeline:

You don't need the body to understand the contract. orders is the input stream: restock reads PurchaseOrder values from it. alerts is an output stream: restock writes StockAlert values into it. The function name and the two directions together describe the function's behavior at a glance.

Compare that to:

The names hint at intent, but the types don't enforce it. A reader has to inspect the body to be sure. A reviewer can't catch a mistake by looking at the signature.

Here's a more complete example showing what good signatures look like in a small pipeline. Each function declares exactly what it does with each channel:

Three roles, three signatures, each one declaring its data-flow shape. placeOrders has a single send-only parameter: pure producer. applyTax has one receive-only and one send-only: a transformer in the middle of a pipeline. printOrders has a single receive-only parameter: pure consumer. The shape of the program is visible in the parameter list of each function.

The implicit conversion handles the wiring. main creates two bidirectional channels, raw and taxed, and passes them through. Each goroutine sees only the restricted view it needs.

This pattern recurs in larger Go programs. Producers take chan<-, consumers take <-chan, transformers take both. Bidirectional chan T parameters are rare in well-written Go, and they're often a smell that suggests the function is doing two jobs at once.

Constructor That Returns a Receive-Only Channel

A common idiom in Go is a constructor function that creates a channel, spawns a goroutine to populate it, and returns the channel to the caller. The catch is that the constructor wants to be the only thing that ever sends or closes. If it returns a plain chan T, the caller could send into it, close it, or do anything else, and nothing would stop them. Returning <-chan T instead gives the caller exactly the permission it needs (receive) and nothing more.

Here's the pattern in its cleanest form. A function generates a stream of product IDs and returns a receive-only channel:

Walk through what's happening with the types. Inside productStream, out has type chan int because make always returns a bidirectional channel. The goroutine sends values into out and closes it. When the function returns out, the return type <-chan int triggers an implicit conversion from chan int to <-chan int. The caller gets a receive-only handle.

From main, there's no way to send into the channel or close it. The caller doesn't even have the option to write incorrect code. If someone tries:

both lines fail at compile time. The constructor pattern keeps the producer-consumer contract enforced by the type system.

This idiom shows up everywhere once you start looking for it. time.After(d) returns <-chan time.Time for exactly this reason: only the runtime should ever send into that channel or close it, so the caller gets a receive-only view. Same with time.Tick, ctx.Done(), and most channel-returning APIs in the standard library.

A real use case might look like this. A function that fetches the latest review scores for a product, returning them as they arrive:

Same pattern. The goroutine inside reviewScores owns the channel: it's the only sender and the only closer. The caller receives values and reads to completion via range, which exits when the goroutine closes the channel.

The pattern also generalizes to multiple return values. A function that returns both a data channel and an error channel typically returns both as receive-only, since the constructor is the only writer of either.

Two channels, both returned as receive-only. The caller can drain both but can't write to either. This shape, one channel for data and one for the final error, is a standard idiom for streaming results from a goroutine that can fail.

Patterns You'll See Later

Direction-restricted types are the glue that holds together most of the channel patterns later in this section. Quick foreshadowing of what's coming, just enough to see why directions matter:

  • Pipelines chain several transformer functions, each with <-chan T input and chan<- U output. The directions make each stage's role obvious and prevent stages from accidentally reaching backwards into the previous stage's channel.
  • Fan-out takes a single input channel and distributes its values across several worker goroutines. The dispatcher takes a <-chan T and produces a slice of chan<- T for the workers.
  • Fan-in merges several input channels into a single output. Each merge function takes multiple <-chan T and writes into a single chan<- T.
  • Done channels for cancellation are nearly always typed as <-chan struct{} for the goroutines that watch them, because consumers should never accidentally close someone else's cancellation signal.

The dance of "who can send, who can receive, who can close" gets encoded in the function signatures of every pattern, and that's what makes the code readable. When you see a function take <-chan Order and return <-chan Receipt, you immediately know it's a pipeline stage, no body inspection needed.

Quiz

Channel Directions Quiz

10 quizzes