Status
Accepted
Context
Slug channels were originally shaped around explicit receive result structs:
Full{ value }
Empty{}
This made channel receive semantics explicit, but it also introduced extra conceptual weight into a part of the language that should feel small, direct, and stream-like.
In practice, Full and Empty make simple channel consumers noisier than they need to be. They also introduce a proto-sum-type pattern before Slug has committed to formal sum types as a broader language feature.
Slug already has nil, and nil naturally represents absence, end-of-stream, or no further value. For channel receive, this gives a simpler and more idiomatic model:
match recv(ch) {
nil => done()
msg => handle(msg)
}
The only major ambiguity is whether nil can also be sent as a channel payload. If nil is both a valid payload and the closed-channel signal, receive becomes ambiguous. To preserve the simple receive shape, channels should reserve nil as a structural signal and reject it as a sent value.
Decision
Slug channels will use nil to signal that a channel is closed and drained.
The Full and Empty channel result structs are removed from the channel receive contract.
Receive Semantics
recv(ch)
Receives a value from ch, blocking until either:
- a value is available, or
- the channel is closed and drained.
recv(ch) returns:
- the received value when one is available
nilwhen the channel is closed and no buffered values remain
Example:
val consume = fn(ch) {
match recv(ch) {
nil => "done"
msg => {
process(msg)
recur(ch)
}
}
}
Sending nil
send(ch, nil)
is a runtime error.
Channels may carry any value except nil.
This keeps nil reserved for channel termination and prevents ambiguity between a real payload and an end-of-stream signal.
Closing Channels
close(ch)
Closes the channel.
Closing a channel means:
- no further sends are allowed
- pending buffered values remain receivable
- once buffered values are drained,
recv(ch)returnsnil - closing an already closed channel is idempotent
Sending on a closed channel is a runtime error.
Select Semantics
A receive case in select receives the same value shape as recv(ch).
select {
recv ch /> fn(msg) {
match msg {
nil => done()
_ => handle(msg)
}
}
after 1000 => timeout()
}
If the selected receive observes a closed and drained channel, the case value is nil.
Non-blocking Receive
tryRecv(ch)
Attempts to receive a value from ch without blocking.
tryRecv(ch) returns:
- the received value when one is immediately available
nilwhen no value is immediately available
For tryRecv(ch), nil deliberately means “nothing was received.” This includes both:
- the channel is open but currently empty
- the channel is closed and drained
Code using tryRecv(ch) should therefore treat nil as “no message to process now,” not as a definitive close signal.
If code needs to distinguish channel closure from temporary absence, it should use blocking recv(ch), select, or a higher-level protocol message before the channel is closed.
Consequences
Positive
- Simplifies the channel receive mental model
- Makes channel consumers smaller and more idiomatic
- Avoids introducing
Full/Emptyas proto-sum-types - Aligns channel receive with EOF/end-of-stream style APIs
- Works naturally with
match, recursion, and call chains - Avoids wrapper allocation for every received value
- Keeps
selectreceive cases simple - Makes
nila clear structural signal for channel termination
Negative
nilcannot be sent through channels as a payload- Existing code using
Full{ value }andEmpty{}must be updated
Neutral
- This changes the public contract of
slug.channel.recv FullandEmptyshould be removed fromslug.channelexportsMANIFEST.aishould describerecv(ch)as returning a payload value ornilsend(ch, payload)should document thatpayloadmust not benil- Buffered channel behavior is unchanged
- Channel close idempotency is unchanged
- Timeout and cancellation behavior are unchanged