Invisible link to canonical for Microformats

ADR-037 Channel Receive Uses nil for Closed and Drained Channels


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
  • nil when 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) returns nil
  • 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
  • nil when 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 / Empty as 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 select receive cases simple
  • Makes nil a clear structural signal for channel termination

Negative

  • nil cannot be sent through channels as a payload
  • Existing code using Full{ value } and Empty{} must be updated

Neutral

  • This changes the public contract of slug.channel.recv
  • Full and Empty should be removed from slug.channel exports
  • MANIFEST.ai should describe recv(ch) as returning a payload value or nil
  • send(ch, payload) should document that payload must not be nil
  • Buffered channel behavior is unchanged
  • Channel close idempotency is unchanged
  • Timeout and cancellation behavior are unchanged