Skip to content

q.Open and q.OpenE

Resource acquisition with defer-on-success cleanup — the (T, error) bubble plus a registered defer cleanup(resource) on the success path.

Signatures

func Open[T any](v T, err error) OpenResult[T]
func OpenE[T any](v T, err error) OpenResultE[T]

func (OpenResult[T])  DeferCleanup(cleanup ...func(T)) T   // 0 or 1 args
func (OpenResult[T])  NoDeferCleanup() T
func (OpenResultE[T]) DeferCleanup(cleanup ...func(T)) T   // 0 or 1 args
func (OpenResultE[T]) NoDeferCleanup() T

.DeferCleanup(...) and .NoDeferCleanup() are both terminals — what actually returns T. q.Open(v, err) on its own returns an OpenResult[T] that exposes nothing else; you always chain one of the two terminals onto it. (Why a method, not an extra arg: Go's multi-return spread only fires when the multi-return call is the sole argument, so q.Open(call(), cleanup) won't compile. The terminal-method shape side-steps that.)

.DeferCleanup accepts zero or one cleanup function:

  • DeferCleanup(cleanup) — explicit cleanup, used for any T.
  • DeferCleanup() — no args; the preprocessor infers the cleanup from T at compile time.

What q.Open does

conn := q.Open(dial(addr)).DeferCleanup((*Conn).Close)

rewrites to:

conn, _qErr1 := dial(addr)
if _qErr1 != nil {
    return /* zeros */, _qErr1
}
defer ((*Conn).Close)(conn)

On error: bubble, no cleanup registered (nothing was acquired). On success: register the deferred cleanup so it fires when the enclosing function returns (whether via normal return or via a later bubble).

.DeferCleanup() (zero args) — preprocessor infers the cleanup

For the common case (a Closer-shaped resource or a channel), let the preprocessor figure it out:

ch   := q.Open(makeChan()).DeferCleanup()        // → defer close(ch)
file := q.Open(os.Open(path)).DeferCleanup()     // → defer func() { _ = file.Close() }()
db   := q.Open(sql.Open(...)).DeferCleanup()     // → defer func() { _ = db.Close() }()

The typecheck pass inspects the resource type T at compile time and dispatches:

T's shape Generated defer
channel type (chan X, chan<- X, <-chan X) defer close(v)
Close() error method defer func() { _ = v.Close() }() (close-time error discarded — pass an explicit cleanup if you need to handle it)
Close() method (no return) defer v.Close()
anything else build error — pass an explicit cleanup or .NoDeferCleanup()

Auto-cleanup composes with the OpenE shape methods:

file := q.OpenE(os.Open(path)).Wrap("loading config").DeferCleanup()

If the type doesn't expose a recognised cleanup shape (no Close, not a channel), the preprocessor surfaces a file:line:col: q: … diagnostic naming the type and pointing at the two acceptable fixes (explicit cleanup function or .NoDeferCleanup()).

.NoDeferCleanup() — opt-in "no cleanup" terminal

Some resources don't need a cleanup at the call site — for example, you might be passing the value off to a long-lived owner that handles teardown elsewhere, or the type genuinely has nothing to release. Spell that intent explicitly with .NoDeferCleanup():

val := q.Open(loadValue(key)).NoDeferCleanup()
// rewrites to:
//     val, _qErr1 := loadValue(key)
//     if _qErr1 != nil { return /* zeros */, _qErr1 }
//     // no defer

.NoDeferCleanup() shares the bubble path with .DeferCleanup(...) — only the success-defer line is omitted. Composes with the OpenE shape methods just like Release does:

val := q.OpenE(loadValue(key)).Wrap("loading").NoDeferCleanup()

Why a separate terminal instead of DeferCleanup(q.NoDeferCleanup) (a no-op cleanup)? Spelling it as a method makes the intent obvious in code review — "we acquired this and we're not closing it, here's the call that says so" — instead of needing to look up what q.NoDeferCleanup does in the docs.

Chain methods on q.OpenE

All of these return OpenResultE[T] so .DeferCleanup can still terminate the chain.

Method Bubbled error
.Err(replacement error) replacement
.ErrF(fn func(error) error) fn(capturedErr)
.Wrap(msg string) fmt.Errorf("<msg>: %w", capturedErr)
.Wrapf(format string, args ...any) fmt.Errorf("<format>: %w", args..., capturedErr)
.Catch(fn func(error) (T, error)) On recover (v, nil) the recovered v is what .DeferCleanup's cleanup later fires on

Example chain:

conn := q.OpenE(dial(addr)).Wrap("dialing").DeferCleanup((*Conn).Close)

and with recovery:

conn := q.OpenE(dial(addr)).Catch(func(e error) (*Conn, error) {
    if errors.Is(e, syscall.ECONNREFUSED) {
        return fallbackConn(), nil    // recovered resource feeds DeferCleanup's cleanup
    }
    return nil, e
}).DeferCleanup((*Conn).Close)

LIFO cleanup across multiple Opens

Two Opens in sequence register two defers. Go defer semantics are LIFO, so the later-acquired resource is released first — matching the defer lock.Unlock() idiom scaled up:

func work(addr, path string) error {
    conn := q.Open(dial(addr)).DeferCleanup((*Conn).Close)
    f    := q.Open(os.Open(path)).DeferCleanup((*os.File).Close)

    // f.Close() runs first (innermost defer), then conn.Close().
    return process(conn, f)
}

If os.Open fails above, conn has already been acquired — its cleanup runs, f's does not.

Statement forms

Works in every position q.Try does:

conn := q.Open(dial()).DeferCleanup(cleanup)                         // define
conn  = q.Open(dial()).DeferCleanup(cleanup)                         // assign
        q.Open(dial()).DeferCleanup(cleanup)                         // discard (side-effect only — cleanup registers)
return  q.Open(dial()).DeferCleanup(cleanup), nil                    // return-position
id   := identify(q.Open(dial()).DeferCleanup(cleanup))               // hoist

Resource-escape detection

A q.Open(...).DeferCleanup(...) value is alive only until the enclosing function returns — that's when the deferred cleanup fires. Letting the value escape that scope is a use-after-close in waiting. The preprocessor catches the obvious shapes and fails the build with a diagnostic.

Three death events make a binding "dead" from a given source point onward:

  1. q.Open(...).DeferCleanup(...) itself — auto-defers cleanup. Dead from the assign line.
  2. defer x.Close() — explicit user-written defer. Dead from the defer line.
  3. x.Close() — synchronous close. Dead from this point onward.

Note that close(ch) and defer close(ch) are NOT death events. Receiving from a closed channel is idiomatic (close(ch); return ch is a legitimate finite-channel factory), so channels are exempt.

Once dead, the binding cannot escape via:

  • return c
  • go fn(c) / defer fn(c) (other than the cleanup itself)
  • field / global / map / index store: p.c = c, m[k] = c, etc.
  • channel send: ch <- c

One-hop alias tracking covers the common c2 := c; return c2 shape. Deeper indirection (passing through function calls, returning from inner closures) is out of scope — flag-everything-that-might-escape produces too many false positives without a real flow analysis.

q.Open(...).NoDeferCleanup() is the explicit "caller takes ownership" form and never makes the binding dead — return it freely.

//q:no-escape-check opt-out

Some test fixtures (notably tests of q.Open's mechanism itself) intentionally factory out a closed resource so the caller can probe its post-close state. Mark such functions with a //q:no-escape-check directive:

//q:no-escape-check
func channelAutoInner() (chan int, error) {
    ch := q.Open(makeChan()).DeferCleanup()
    ch <- 7
    return ch, nil
}

Real user code shouldn't need this — the patterns it suppresses are bug-shaped in production. The directive exists so we can write tests that verify q.Open's deferred close actually fires.

See also

  • Design — why .DeferCleanup is terminal