q — design¶
This is the authoritative design document for q, the question-mark-operator preprocessor for Go. The user-facing tour lives in README.md; resume-state and conventions live in CLAUDE.md. This document records why the design is what it is — the constraints that shaped it, the alternatives considered and rejected, and the contract every implementation phase must honour.
1. Goals¶
- Flat call sites. A user writing several
(T, error)-returning calls in sequence should not have to interleave them withif err != nil { return …, err }blocks. The Rust?operator and Swift'stryare the role models. - Zero runtime overhead. The generated code must be the same as what a careful programmer would write by hand. No closures, no panic/recover, no reflection.
- IDE-native.
gopls,go vet, editors, and language servers must see ordinary Go at all times. No special syntax, no shadow files, no IDE plugins. - Loud failure for misuse. Forgetting the preprocessor must fail the build, not silently produce a working-looking binary that drops errors. Same for any rewriter bug that leaves a
q.*call site untransformed.
2. The user-facing surface¶
2.1 Bare bubble¶
v := q.Try(call) // (T, error) — on err, bubble err
p := q.NotNil(ptr) // (*T) — on nil, bubble q.ErrNil
q.Check(errOnlyCall) // error — on err, bubble err (stmt only; void return)
c := q.Open(openCall).DeferCleanup(cleanup) // (T, error) + cleanup(T) — bubble on err; defer cleanup(c) on success
These are the 90% case. The bubble is unconditional: the source error (or sentinel) is forwarded unchanged to the enclosing function's return. q.Open is the one exception where "success" does more than pass the value through — it registers a deferred cleanup in the enclosing function, so the next thing this function's return does (normal or bubble) runs cleanup(c).
2.2 Chain — custom error handling¶
When the call site needs to wrap, transform, or recover from the failure, the E-suffixed entries carry the captured value-plus-error into a Result value with method options:
v := q.TryE(call).Err(constErr) // replace the source err with constErr
v := q.TryE(call).ErrF(fn) // fn(err) error — transform
v := q.TryE(call).Wrap(msg) // fmt.Errorf("<msg>: %w", err)
v := q.TryE(call).Wrapf(format, args...) // fmt.Errorf("<format>: %w", args…, err)
v := q.TryE(call).Catch(fn) // fn(err) (T, error) — transform OR recover
p := q.NotNilE(ptr).Err(constErr)
p := q.NotNilE(ptr).ErrF(fn) // fn() error — computed (no source err to pass in)
p := q.NotNilE(ptr).Wrap(msg) // errors.New(msg) — no %w, no source err
p := q.NotNilE(ptr).Wrapf(format, args...) // fmt.Errorf(format, args...)
p := q.NotNilE(ptr).Catch(fn) // fn() (*T, error) — computed value OR error
q.CheckE(err).Err(constErr) // same vocabulary, void return
q.CheckE(err).ErrF(fn)
q.CheckE(err).Wrap(msg)
q.CheckE(err).Wrapf(format, args...)
q.CheckE(err).Catch(fn) // fn(err) error — nil suppresses, non-nil bubbles
c := q.OpenE(openCall).Err(constErr).DeferCleanup(cleanup) // replace the err, then defer cleanup
c := q.OpenE(openCall).Wrap(msg).DeferCleanup(cleanup) // wrap the err, then defer cleanup
c := q.OpenE(openCall).Catch(func(e error) (T, error) { ... }).DeferCleanup(cleanup) // recover OR bubble, then defer cleanup on whichever T wins
Catch is the union of "transform the error" and "recover with a fallback value". Returning (value, nil) short-circuits the bubble and uses the value; returning (zero, err) bubbles err. The simpler methods (Err, ErrF, Wrap, Wrapf) are sugar for common shapes that Catch could express but that read better as named operations. For q.OpenE, the recovered value is what the deferred cleanup fires on — not the failed resource.
2.3 Constraints that shaped this surface¶
Why a chain instead of q.NoErrf(call(), format, args…)-style overloads? Go's spread-multi-return-into-arguments rule fires only when the multi-return call is the sole argument. So q.NoErrf(strconv.Atoi(s), "fmt", x) is a compile error: once you add format args, you can no longer spread (T, error) into the leading two parameters. The chain side-steps this by making the multi-return call the only argument to q.TryE, then chaining a method whose receiver has already absorbed the spread. The same constraint is why q.Open's cleanup arrives via a terminal .DeferCleanup(cleanup) method rather than as a second argument to q.Open(call(), cleanup).
Why one bubble entry per source signature? Go's type system can't overload — a single q.Try can't accept both (T, error) and plain error. Splitting by source signature is what the language allows, and it makes the call site self-documenting: q.Check reads as "the thing I'm calling returns error", q.Open reads as "I'm acquiring a resource that needs cleanup". The E suffix per family carries the chain variant.
The original four bubble entries cover the dominant Go signatures:
| Source | Entry |
|---|---|
(T, error) |
Try / TryE |
*T |
NotNil / NotNilE |
error alone |
Check / CheckE (void — stmt only) |
(T, error) + cleanup on success |
Open / OpenE |
Subsequent additions follow the same shape: each new helper picks a distinct source signature and exposes a bare + chain pair. q.Ok / q.OkE for (T, bool), q.Recv / q.RecvE and q.As / q.AsE as comma-ok specialisations, q.Await* / q.Recv*Ctx / q.CheckCtx* for context cancellation and futures, and so on. The bubble shape is the constant; what varies is the trigger (error, nil, not-ok, ctx, channel close) that fires it.
Why terminal .Release for Open (not .WithDefer earlier in the chain)? .DeferCleanup(cleanup) has to own both the error-bubble path and the success-defer path. Making it a modifier in the middle of the chain would mean another method comes after it — but that method can't undo the defer registration, so Release's placement relative to other chain methods would matter. As the terminal, Release's position is unambiguous: error shaping happens first, defer registration on success is the last step.
Why different entry verbs for the two source-monads (Try vs NotNil)? Forced symmetry — TryNil — parses backwards in English. Same reason Check and Open aren't TryError and TryManage. The source signatures are genuinely different in shape, so different verbs read more honestly than enforced symmetry.
Why .Err / .ErrF / .Catch rather than a single variadic / overloaded method? Each method's signature directly tells the user what is allowed: a constant error, an error transformer, or a value-or-error producer. The preprocessor pattern-matches on method name to pick the right rewrite template — one inlined if err != nil { return zero, <expr> } block per method, no runtime dispatch. The .Wrap / .Wrapf shortcuts are pure ergonomics for the most common case.
3. The link gate¶
pkg/q declares a single bodyless function bound via //go:linkname to an external symbol that only the q preprocessor's toolexec pass supplies:
//go:linkname _qLink _q_atCompileTime
func _qLink()
// Force linker resolution; without this the linker may drop _qLink as unused
// and the gate disengages silently.
var _ = _qLink
The single package-level reference is enough to make the linker insist on resolving _q_atCompileTime. With -toolexec=q, the preprocessor's Phase 1 pass parses pkg/q's sources, finds the //go:linkname directive, and synthesizes a companion .go file that supplies _q_atCompileTime as a no-op — link succeeds. Without the preprocessor, the symbol is undefined and the link fails:
This is by design: the link failure is the contract that says "you forgot the preprocessor". Both halves of the contract are tested:
internal/preprocessor/e2e_test.gobuilds fixtures with-toolexec=qand asserts the build succeeds (and, when the fixture providesexpected_run.txt, that the runtime stdout matches).internal/preprocessor/linkgate_test.gobuilds the same shape without-toolexec=qin an isolatedGOCACHEand asserts the link fails with the expected substring.
3.1 Why bodies, not bodyless declarations¶
The natural question: why not declare Try, TryE, NotNil, NotNilE (and the chain methods) as bodyless //go:linkname declarations directly, with no Go body at all?
Because they are generic. Try[T any] produces one mangled symbol per type instantiation (q.Try[int], q.Try[string], …); //go:linkname redirects a single local symbol to a single external one. There is no way to spell "every instantiation of q.Try links to the same external stub". So the bodies must exist; the question is what they contain.
The chosen body is panic("q: <name> call site was not rewritten by the preprocessor") followed by return <zero>. The panic ensures any rewriter miss surfaces loudly at runtime. The return <zero> keeps the function type-correct so the package compiles.
The panic message names the helper, so when it fires the user can grep for which q.* form caused it and either file a bug, refactor the call site to a supported pattern, or upgrade q.
4. The rewriter¶
For each q.* call expression in a user-package source file, the preprocessor pattern-matches the expression's full shape and emits a replacement.
4.1 Shapes the rewriter recognises¶
| Source | Replacement (sketch) |
|---|---|
v := q.Try(call()) |
v, __err := call(); if __err != nil { return zero…, __err } |
v := q.TryE(call()).Err(E) |
v, __err := call(); if __err != nil { return zero…, E } |
v := q.TryE(call()).ErrF(fn) |
v, __err := call(); if __err != nil { return zero…, fn(__err) } |
v := q.TryE(call()).Wrap("msg") |
v, __err := call(); if __err != nil { return zero…, fmt.Errorf("%s: %w", "msg", __err) } |
v := q.TryE(call()).Wrapf("fmt", a, b) |
v, __err := call(); if __err != nil { return zero…, fmt.Errorf("fmt: %w", a, b, __err) } |
v := q.TryE(call()).Catch(fn) |
v, __err := call(); if __err != nil { var __new error; v, __new = fn(__err); if __new != nil { return zero…, __new } } |
p := q.NotNil(expr) |
p := expr; if p == nil { return zero…, q.ErrNil } |
p := q.NotNilE(expr).Err(E) ... (other methods mirrored) |
p := expr; if p == nil { return zero…, E } |
q.Check(call()) (stmt) |
__err := call(); if __err != nil { return zero…, __err } |
q.CheckE(call()).Wrap("msg") (stmt) |
__err := call(); if __err != nil { return zero…, fmt.Errorf("%s: %w", "msg", __err) } |
q.CheckE(call()).Catch(fn) (stmt) |
__err := call(); if __err != nil { __new := fn(__err); if __new != nil { return zero…, __new } } |
c := q.Open(call()).DeferCleanup(cleanup) |
c, __err := call(); if __err != nil { return zero…, __err }; defer (cleanup)(c) |
c := q.OpenE(call()).Wrap("msg").DeferCleanup(cleanup) |
c, __err := call(); if __err != nil { return zero…, fmt.Errorf("%s: %w", "msg", __err) }; defer (cleanup)(c) |
c := q.OpenE(call()).Catch(fn).DeferCleanup(cleanup) |
c, __err := call(); if __err != nil { var __new error; c, __new = fn(__err); if __new != nil { return zero…, __new } }; defer (cleanup)(c) |
Zero values come from the enclosing FuncDecl.Type.Results or FuncLit.Type.Results field (whichever is the nearest-enclosing function scope — closures bubble to their own result list, not the outer FuncDecl's). The rewriter walks the AST, finds the nearest-enclosing function for each call site, and emits an appropriate zero value per result type via *new(T). That form is universal — works for built-ins, user types, pointers, interfaces, and type parameters in generic bodies — and the Go compiler folds it to a constant zero, so the generated machine code is identical to a hand-written zero literal.
4.1.1 Statement forms¶
Every value-producing helper (Try, NotNil, Open and their E variants) works in five statement positions:
| Form | Shape | Notes |
|---|---|---|
| define | v := q.Try(call()) |
LHS is a fresh identifier |
| assign | v = q.Try(call()), m[k] = ... |
LHS is any addressable expression without nested q.* |
| discard | q.Try(call()) (ExprStmt) |
Value is dropped; bubble still fires |
| return | return q.Try(call()), nil |
q.* anywhere inside any return-result expression |
| hoist | v := f(q.Try(call())) |
q.* nested inside any non-return expression |
q.Check / q.CheckE return void, so they only appear as expression statements. Multiple q.s per statement compose via the hoist path: return q.Try(a()) * q.Try(b()), nil, x := q.Try(Foo(q.Try(Bar()))), m[q.Try(k())] = v. The rewriter orders nested q.s innermost-first, allocates _qTmpN counters in render order, and rebuilds the final statement with each q.* span substituted by its temp.
4.2 What the rewriter must reject¶
Any q.* call that does not match one of the recognized shapes is a hard error: the preprocessor emits a file:line:col: q: unsupported call shape: <reason> diagnostic and exits non-zero. The rewriter must never silently leave a q.* call in the compiled output (the panic body would then fire at runtime, but that is the backstop, not the contract).
Examples of explicit rejections:
q.Tryoutside any function body (e.g. as a package-level initializer).- A
q.*call inside a function with no return values (func f() { q.Try(x()) }) — the bubble has nowhere to go. q.TryE(call).Method(...)wherecallis not itself a multi-return(T, error)expression — the AST path needs both pieces to type-check the chain.- A chain method that is not one of the recognized names. (Library evolution requires updating the rewriter and a fixture in the same change.)
q.TryE(call).Wrapf(format, args…)whereformatis not a string literal — the rewriter splices: %winto the format, which requires it to be a literal.q.Open(call).DeferCleanup(cleanup)missing the.Releaseterminal — the scanner surfaces this as a diagnostic because the resultingOpenResult[T]would be a useless intermediate value.
4.3 What the rewriter must preserve¶
- Source positions for compile errors and debuggers. When the user package itself has a compile error, the column / line numbers
cmd/compilereports must point to the user's source, not to the rewritten temp file. Same for DWARF — IDE breakpoints set against the user's path must match what the binary's debug info records.qachieves this via//linedirectives: a file-level//line <user-path>:1is prepended to each rewritten file, and each per-statement rewrite is followed by a//line <user-path>:<line-after-stmt>directive so the extra lines the rewrite injects don't shift subsequent mappings. Debuggers,go vet, and the compiler all honour these directives, so DWARF / error messages show the user's path and the line of the original q.* call. - Imports. If the rewriter introduces
fmtorerrorscalls, it must add the import (deduped against existing imports). - Side-effect order. The replacement must evaluate the inner call exactly once and bind its results before the if-check. Naïve textual substitution that re-evaluated
call()would change semantics.
4.4 Cross-package considerations¶
For Phase 2, the rewriter operates on each user package's compile in isolation: it needs to know that q.Try resolves to github.com/GiGurra/q/pkg/q.Try, but it does not need to walk into pkg/q's sources. The chain shapes (q.TryE(...).Err(...)) are syntactic — the pkg/q import alias is enough to disambiguate.
Cross-package cases (e.g. a user wraps q.Try in their own helper) are out of scope. Such helpers will trigger the runtime panic backstop until / unless a future phase adds inlining.
5. Phasing¶
- Phase 1 — link gate + stub injection. Done.
pkg/qlink-gates via_qLink,cmd/qinjects the no-op stub intopkg/q's compile. E2e harness verifies both halves of the contract. - Phase 2 — rewriter for the Try family. Done.
q.Try(call)andq.TryE(call).Method(args)across all five statement forms. - Phase 3 — rewriter for the NotNil family. Done. Mirror of Phase 2.
- Phase 4 — return-position + nested-in-expression. Done.
return q.Try(call()), niland nested q.* inside any expression via the hoist form. Multi-q-per-statement composes (includingq.Try(Foo(q.Try(Bar())))). - Phase 5 — error-only (Check) + resource-with-cleanup (Open). Done. Adds
q.Check/q.CheckEfor functions returning justerror, andq.Open/q.OpenEfor defer-on-success cleanup. - Phase 6 — closures / anonymous functions. Done. Scanner recurses into
*ast.FuncLitbodies; each uses its ownFuncType.Resultsfor the bubble. - Phase 7 — typed-nil-interface guard. Done.
internal/preprocessor/typecheck.goruns ago/typespass over each user-package compile (importer backed by the compile's-importcfg) and requires every q. error-slot type to be exactly the built-inerrorinterface. Concrete types that satisfyerrorvia method sets (e.g.*MyErr) are rejected with a diagnostic naming the offending type. Motivated by Go's implicit concrete-to-interface conversion: a nil*MyErrbecomes a non-nil*errorinterface value, so the rewrittenif err != nilwould fire for a notionally-nil error. Canonically mistake #45 in 100 Go Mistakes. Ships withq.ToErr, a runtime adapter helper that unblocks legitimate(T, *E)callees by collapsing typed-nil to literal nil. See Typed-nil guard for the user-facing spelling.
Future / deferred:
- A counterpart helper for cases where the bubble trigger is neither
(T, error)nor*T == nilnorerroralone. Possibilities:q.IfNil(x)for is-nil-as-failure on an interface or chan;q.Ok(v, ok)for the comma-ok pattern;q.Recv(ch)for channel close. Exact semantic to be agreed when there's a real motivating use case. Tracked as TODO #11. - Multi-LHS where q. itself produces multiple T values (
v, w := q.Try2(call())) — needs new runtime helpers. Incidental multi-LHS (where q. is nested inside a multi-result RHS call) already works via hoist. Tracked as TODO #16, parked. - Optimisations like length-preserving rewrites if the position-drift impact in editors / CI becomes annoying.
5b. Runtime-package injection¶
q's preprocessor injects companion files into the stdlib runtime package compile to expose runtime-internal information that Go's public API hides. As of writing, one such injection is shipped (q.GoroutineID() exposes g.goid); a goroutine-local-storage feature using the same mechanism plus an AST patch of goexit0 is parked — see docs/planning/TODO.md #67 for the design discussion and the resume-point.
The mechanism: when toolexec dispatches the runtime package compile, q's planRuntimeStub synthesizes one or more companion files and substitutes/appends them into the compile argv. The injected file uses single-arg //go:linkname directives to opt symbols into being externally linkable (Go 1.23+ blocks third-party linkname pulls into runtime unless the runtime side has explicitly declared the symbol so). pkg/q then //go:linkname-pulls those symbols.
Additions that need a call site inside an existing runtime function (e.g. a goroutine-death cleanup hook) require modifying that runtime file in place, not just appending a new one. The patcher walks the file with go/parser, finds the target function by name, manipulates the AST, and prints the result to a tempdir. This is materially riskier than appending — a bug in the patcher breaks runtime — and we keep this kind of change behind a planning doc and a fixture before shipping.
6. Non-goals¶
- No general monad library.
qis the Rust-?analogue; it is notfor { x <- … }from Scala. If the project ever needed a wider effect surface, that is a separate project, not a feature ofq. - No type-level guarantees. The rewriter is purely syntactic. Whether
call()actually returns(T, error)is left to the Go compiler to verify after the rewrite (which it will, because the inlinedv, __err := call()is what the user would have written). - No support for outside the function body.
q.*calls in package-levelvarinitializers, in struct field tags, etc., are outside scope. The rewriter rejects them with a diagnostic.
7. The golden rule: q only accepts Go-valid syntax¶
Everything q exposes to the user must parse and type-check as plain Go — what go build / gopls / the IDE's analyzer sees before the toolexec pass ever runs. If a proposed ergonomic improvement would require Go to accept syntax it doesn't, we reject the proposal. No exceptions.
Some shapes that would read nicely but are deliberately rejected:
- Auto-inferring a trailing
, nilon a return.return q.Try(strconv.Atoi(s)) * 2inside a(int, error)function looks clean, but it is invalid Go: areturnstatement needs as many values as the function signature declares. Every editor would light it up red. We require the user to write the explicit, niltail. - Auto-injecting a trailing
return nilat the end of anerror-returning function. Same reason: a function declared to returnerrormust end with an explicit return in Go's grammar (or be otherwise unreachable). Synthesising it at preprocess time would hide that requirement from gopls. - Omitted return values in multi-return functions. Any shape where "q fills in the rest" would show as a type error in the editor.
We could implement all of the above — the rewriter sees the AST and could emit whatever the compiler accepts. But the value proposition of q is precisely that its user surface is indistinguishable from well-typed Go: completion works, go-to-definition works, refactors work, rename works, type errors point at the right places. The instant we accept non-Go input, we start fighting the tooling on behalf of the user — and we lose the exact reason we chose a toolexec rewriter over a custom parser. Tooling-native > source-density.
Counter-rule: this does NOT constrain what the rewrite output looks like. The generated bind + check blocks, _qTmpN temporaries, *new(T) zero values, etc., live only in temp files the compiler reads and never see an editor. They just need to compile and behave identically to hand-written error forwarding.