Background
Go channels are one of the best things about the language. But the moment you need context cancellation, error propagation, and safe concurrent shutdown all at once, a simple chan T starts asking you to write a lot of code just to use it correctly. A common pattern looks something like this:
out := make(chan Result, len(urls))
errc := make(chan error, 1)
go func()
defer close(out)
for _, url := range urls
select
case <-ctx.Done():
errc <- ctx.Err()
return
default:
resp, err := fetch(ctx, url)
if err != nil
errc <- err
return
select
case out <- ResultData: resp:
case <-ctx.Done():
errc <- ctx.Err()
return
()
This works. But if you're not careful, it's easy to introduce bugs:
Double-close panics — closing a channel twice crashes the program
Goroutine leaks — the writer forgets to close, readers block forever
Lost values — values buffered before close are never read
Zombie producers — the writer keeps running after the consumer has already failed
The core problem is that Go channels are low-level primitives. They're powerful, but they leave all the hard parts entirely to the developer.
The Inspiration: io.Pipe
Before reaching for a channel, consider io.Pipe. It has an elegant design that solves many of the problems above.
io.Pipe is purpose-built for streaming bytes between goroutines — reading a file, proxying an HTTP body, piping command output. It handles backpressure, idempotent close, and error propagation cleanly:
pr, pw := io.Pipe()
go func()
defer pw.Close()
io.Copy(pw, file) // stream file bytes to the reader
()
io.Copy(dst, pr) // consume on the other end
But it only works with []byte. The moment your data is a struct, you're out of luck — io.Pipe can't help you there.
That's exactly the gap go-typedpipe fills — taking the same design philosophy as io.Pipe and making it work for any type T.
Enter go-typedpipe
go-typedpipe is io.Pipe, but for any type T.
w, r := typedpipe.New[Event]()
go func()
defer w.Close()
for _, event := range events
if err := w.Write(ctx, event); err != nil
return
()
err := r.ReadAll(ctx, func(event Event) error
return process(event)
)
No separate error channel. No manual select on every send. No serialization. The same guarantees as io.Pipe — plus buffering, context-awareness, and a drain guarantee.
What it adds over a raw channel
chan T
go-typedpipe
Context-aware blocking
Manual select on every send/receive
Built into Write and Read
Close error propagation
Not supported
CloseWithError propagates to all consumers
Safe concurrent close
Panics on double-close
Idempotent, safe to call multiple times
Drain guarantee
Values may be lost after close
All buffered values remain readable after close
Consumer loop
Boilerplate for range or select
ReadAll encapsulates the loop
How It Works
Understanding the internals will make you a better Go developer, not just a better user of this library. Let's walk through the key decisions one by one.
Under the hood, the pipe holds two channels:
type pipe[T any] struct
ch chan T // carries values from writers to readers
done chan struct // closed once on shutdown
once sync.Once
err pipeError
Why ch is never closed
ch is never closed. This is the most important design decision in the library. In Go, sending to a closed channel causes an immediate panic — and there's no way to recover from it gracefully. Here's what that looks like:
ch := make(chan int, 1)
close(ch)
ch <- 1 // panic: send on closed channel
In a concurrent setting with multiple writers, this becomes a real danger. Two goroutines racing to close the same channel will also panic:
go func() close(ch) () // goroutine 1
go func() close(ch) () // goroutine 2 — panic: close of closed channel
go-typedpipe sidesteps the problem entirely by never closing ch. Instead, shutdown is signaled by closing done — a separate zero-value channel that carries no data, only a signal. Writers and readers select on done to know when to stop. ch stays open for the lifetime of the pipe, so there's no window for a send-on-closed panic, no matter how many goroutines are writing concurrently.
You might wonder: is it safe to never close ch? Yes — Go has a garbage collector that automatically cleans up memory that is no longer used by the program. Think of it like this: as long as something in your code is still holding a reference to an object, the GC will leave it alone. Once nothing references it anymore, the GC will reclaim the memory — no manual cleanup needed. This applies to channels too. Once the pipe goes out of scope and n
Tags:
#0
Want to run a more efficient business?
Mewayz gives you CRM, HR, Accounting, Projects & eCommerce — all in one workspace. 14-day free trial, no credit card needed.
Try Mewayz Free →