A small, generic versioned registry. It maps (version, name) pairs to values
of any type F — a handler, a factory, a codec, a validator, anything.
Versions are additive: each version inherits every entry from the one before it
and may override individual names.
No global state. No lazy init. No dependencies beyond the standard library.
You only register what changes. Every version implicitly inherits everything from the version before it.
foo |
bar |
baz |
qux |
new |
|
|---|---|---|---|---|---|
| v0 | defined | defined | defined | defined | — |
| v1 | defined | (v0) | override | (v0) | — |
| v2 | (v1) | (v0) | (v1) | (v0) | defined |
reg.Resolve(2, "bar")→ the value registered in v0reg.Resolve(1, "baz")→ the override registered in v1reg.Resolve(2, "baz")→ the same v1 override, inherited by v2
Protocol message dispatch — a network protocol where each version may add
or change message types. Register a handler or factory per (version, verb)
pair; after version negotiation with a peer, a single Resolve call finds the
right one.
API request handlers — a versioned HTTP or RPC API where /v2/payments
shares most logic with /v1/payments but a few endpoints changed. Register
handlers by version and route; only override the routes that differ.
File format codecs — a file format that evolves across versions. Register a
decoder per (version, chunk-type); older chunk types are automatically
inherited by newer format versions.
Feature flags or validators — any keyed behaviour that needs to evolve across numbered releases without re-registering unchanged entries every time.
type Handler func(Input) (Output, error)
reg := verreg.NewRegistry[Handler]()
// Version 0 — initial set
reg.Register(0, "foo", fooV0)
reg.Register(0, "bar", barV0)
reg.Register(0, "baz", bazV0)
// Version 1 — only declare what changes
reg.Register(1, "baz", bazV1)
// Mark versions that should no longer be used
reg.DeprecateVersion(0)
// Build must be called once before any Resolve call
reg.Build()Looking up an entry:
handler, err := reg.Resolve(version, name)
switch {
case errors.Is(err, verreg.ErrDeprecatedVersion):
// version is deprecated; ask the caller to upgrade
case errors.Is(err, verreg.ErrUnknownVersion):
// version was never registered
case errors.Is(err, verreg.ErrUnknownEntry):
// name not registered for this version
case err != nil:
// verreg.ErrNotBuilt — Build() was not called
}
result, err := handler(input)The caller already holds version and name, so the sentinel errors carry no
redundant payload. Wrap them yourself if you need context in logs:
if err != nil {
return fmt.Errorf("resolve %q (v%d): %w", name, version, err)
}Register resets the built flag. Call Build again after any late additions:
reg.Register(2, "upload", uploadV2)
reg.Build() // re-flattens; prior Resolve results are unaffectedif !reg.SupportsVersion(v) {
// reject the connection before dispatching any messages
}SupportsVersion is safe to call without Build — it reports on the flattened
table, so it returns false for anything not yet built.
| Sentinel | Condition |
|---|---|
ErrNotBuilt |
Resolve called before Build |
ErrDeprecatedVersion |
version was marked with DeprecateVersion |
ErrUnknownVersion |
version has no registrations at all |
ErrUnknownEntry |
name not registered for that version |
All sentinels are plain errors.New values — no allocation on the error path,
compatible with errors.Is.
None.