q.Atom / q.A / q.AtomOf — typed-string atoms with type-derived values¶
Erlang-flavoured atoms, adapted to Go's type system: ad-hoc symbolic constants whose identity is their name, with no central declaration block to maintain.
type Pending q.Atom // each atom is its own type — no const decl needed
type Done q.Atom
type Failed q.Atom
// In package "github.com/me/proj":
p := q.A[Pending]() // p has type Pending; value is "github.com/me/proj.Pending"
d := q.A[Done]() // d has type Done; value is "github.com/me/proj.Done"
Surface¶
type Atom string
func A[T ~string]() T // typed instance: returns T("<importPath>.<TypeName>")
func AtomOf[T ~string]() Atom // q.Atom-typed instance: returns Atom("<importPath>.<TypeName>")
The preprocessor rewrites both call sites in place at compile time to a fully-qualified typed-string cast — the import path of the declaring package plus the bare type name:
// In package "github.com/me/proj":
q.A[Pending]() → Pending("github.com/me/proj.Pending")
q.AtomOf[Pending]() → q.Atom("github.com/me/proj.Pending")
So the rewritten code is just a typed-string constant cast. Zero runtime cost; nothing reflective; nothing that runs at startup.
The fully-qualified value is the load-bearing wire identity for each atom — see Atom values are import-path-qualified below for the design rationale and the tradeoffs around serialization.
What you get¶
- Each atom is its own type. Go's type system protects against
mixing — you can't pass a
Pendingwhere aDoneis expected. - No declaration boilerplate.
type Pending q.Atomis the entire declaration; the value is auto-derived. - Decentralized. Different files / packages can introduce new atoms without touching a shared list.
- Cross-package equality is automatic. Two atoms with the same name in different packages compare equal via plain string equality (modulo the type-distinction safety net — see below).
When to use which¶
| Scenario | Reach for |
|---|---|
| Typed value to pass / store / return | q.A[T]() — preserves type distinction |
Switch-case expression on a q.Atom-typed value |
q.AtomOf[T]() — pre-cast to the parent type |
| Map key (typed) | q.A[T]() |
Constant comparison with a q.Atom |
q.AtomOf[T]() |
Both helpers use the same underlying value — q.A[Pending]() and
q.AtomOf[Pending]() produce the same fully-qualified string
("github.com/me/proj.Pending"). The difference is the static type
each returns.
Examples¶
type Pending q.Atom
type Done q.Atom
type Failed q.Atom
// Type-distinct values — function only accepts Pending atoms:
func ack(p Pending) string {
if p == q.A[Pending]() {
return "still pending"
}
return "unexpected"
}
// In package "github.com/me/proj":
p := q.A[Pending]() // p: Pending = "github.com/me/proj.Pending"
ack(p) // OK
// ack(q.A[Done]()) // compile error — type mismatch
// Switch over the parent q.Atom type:
func classify(a q.Atom) string {
switch a {
case q.AtomOf[Pending]():
return "p"
case q.AtomOf[Done]():
return "d"
case q.AtomOf[Failed]():
return "f"
}
return "?"
}
// Atoms as map keys:
counts := map[Pending]int{q.A[Pending](): 0}
counts[q.A[Pending]()]++
Atom values are import-path-qualified¶
Every atom's runtime value is its declaring package's full import path + the bare type name, separated by a dot:
// In package "github.com/me/proj":
type Status q.Atom
s := q.A[Status]()
fmt.Println(string(s)) // "github.com/me/proj.Status"
This is the load-bearing identity. Two packages defining type Foo q.Atom
produce distinct atom strings — "github.com/me/a.Foo" vs
"github.com/me/b.Foo" — and stay distinct at the q.Atom (parent)
level. The qualified value uses the canonical import path from
go/types, so import aliases at the use site
(import x "github.com/me/a") don't affect the atom's identity — the
canonical underlying type always wins.
This is what enables atoms to be decentralized but globally unique: any package can declare new atoms without coordinating with a central registry, and equality is well-defined across the whole binary.
Implications for serialization (JSON / wire formats)¶
Atoms work well as in-process tags. They are not designed as wire-format identifiers without explicit thought:
- JSON marshalling is automatic because atoms have a string
underlying type —
json.Marshalwrites the atom as a quoted string. But the string the wire sees is the fully-qualified path:"github.com/me/proj.Pending", not"Pending". Producers and consumers of that JSON must agree on the import path of the declaring package, including its version. - Refactoring breaks the wire. Renaming the package, moving the type to a sibling package, or moving the module to a different module path changes the atom's value — and therefore the JSON serialization. If a consumer hard-coded the previous string, it breaks silently.
- Cross-language interop is awkward. A Python client that wants
to send
Pendingwould have to know to send"github.com/me/proj.Pending"— surprising and version-coupled.
For wire-format use cases, two patterns work better than raw atom strings:
- Wrap the atom in a custom MarshalJSON. Define
MarshalJSON/UnmarshalJSONon each atom type to translate to/from the bare name ("Pending") at the wire boundary. Decentralised internally, centralised at the wire.
func (Pending) MarshalJSON() ([]byte, error) { return []byte(`"Pending"`), nil }
func (*Pending) UnmarshalJSON(b []byte) error { /* validate b == "Pending" */ }
- Use plain typed-string constants for wire-bound enums.
Closed-set enums +
q.GenEnumJSONStrict/q.GenEnumJSONLaxgive marshallers, parser, exhaustive coverage. Seeq.Exhaustiveandq.GenEnumJSON*for the closed-set toolchain. Atoms are the open-set sibling; reach for the closed-set tools when wire identity is part of the contract.
The verbose value also shows up in fmt.Print(atom) output:
string(q.A[Status]()) returns "github.com/me/proj.Status", not
"Status". For short readable atom values where the import-path
identity isn't needed, plain typed-string constants are the right
tool.
Caveats¶
- Open set. There's no compile-time check that you've handled
every atom in a switch — atoms are ad-hoc by design. For
closed-set enums use a typed const block +
q.Exhaustiveinstead. - Constraint is
~string, not~Atom. Go's type-name unwrapping meanstype Pending q.Atomhas underlying typestring(notAtom); a~Atomconstraint would only match the bareAtomtype, not user-derived ones.~stringis the next-best option. The preprocessor compensates: it validates at compile time that T is a named type withstringunderlying, rejecting bareq.A[string]()or non-string types with directed diagnostics. Strict "T transitively derives from q.Atom in its TypeSpec chain" validation is a future extension — for now any named string-typed type is accepted.
See also¶
q.FnParams/q.ValidatedStruct— required-by-default struct literals; another way to bring more compile-time discipline into the call site.q.Exhaustive— for closed-set enums where exhaustive switch coverage matters.q.Match— value-returning switch with multiple branches.