A protoc / buf plugin
(protoc-gen-go-const) that generates a read-only interface view for
every message in your .proto files, alongside the standard
protoc-gen-go output.
For each message Foo, it emits:
Foo_Const— a Go interface with a read-only view ofFoo. Scalar / enum /bytesfields keep their plainGetName()getter; message /repeated/mapfields whose signatures differ from the concrete*Fooare exposed through companionConstName()methods. A function that takesFoo_Constphysically cannot mutate the message.func (x *Foo) AsConst() Foo_Const { return x }— a zero-allocation cast.*Fooitself implementsFoo_Const, so there is no wrapper struct, no per-call allocation, and no indirection on scalar getters.func (x *Foo) Const<Name>() ...— one companion method per field whose signature had to change (singular message →T_Const,[]T→goconst.Slice[T],map[K]V→goconst.Map[K,V]), attached directly to*Fooin the generatedfoo.const.pb.go.
Repeated and map fields are returned through
goconst.Slice / goconst.Map — small
read-only collection interfaces that preserve len, index / key lookup
and ranged iteration without leaking mutation (no append, no slot
assignment).
The goal is to let API boundaries (service layers, caches, event handlers, goroutine handoffs, …) express "I only read this message" at the type level, without copying the protobuf or writing hand-maintained DTOs.
Generated protoc-gen-go structs expose every field as a mutable Go
field. Once a *Message crosses an API boundary the callee can write to
it, sort its slices in place, overwrite map values, etc. — and the
compiler will not stop them. *_Const views turn "please don't mutate
this" comments into a compile-time contract:
func Render(user userpb.User_Const) string { // read-only at the type level
return user.GetName() // ✅
// user.Name = "x" // ✗ interface has no such field
}
Render(u.AsConst()) // call site opts in — no copy, no allocationGiven a message like
message Envelope {
string id = 1;
Address addr = 2; // singular message
repeated Address history = 3;// repeated message
map<string, Address> by_tag = 4;
}the plugin generates (roughly):
import (
goconst "github.com/Kybxd/goconst"
proto "google.golang.org/protobuf/proto"
)
// Envelope_Const is a read-only interface view of Envelope.
//
// *Envelope itself satisfies this interface: scalar / enum / bytes
// getters are inherited from the concrete type unchanged, and the
// message / repeated / map getters whose signatures differ are
// exposed via Const<Name> methods generated directly on *Envelope.
type Envelope_Const interface {
proto.Message
goconst.DoNotCompare
GetId() string
ConstAddr() Address_Const
ConstHistory() goconst.Slice[Address_Const]
ConstByTag() goconst.Map[string, Address_Const]
}
var _ Envelope_Const = (*Envelope)(nil)
// AsConst returns x as its read-only Envelope_Const view.
//
// This is a zero-allocation cast: *Envelope already implements
// Envelope_Const, so the receiver is returned unchanged.
func (x *Envelope) AsConst() Envelope_Const {
return x
}
// IsNil reports whether x is nil. It lets callers test the
// "no Envelope behind this view" condition without falling
// into the typed-nil trap that `view == nil` would cause on a
// *Envelope boxed into the Envelope_Const interface.
func (x *Envelope) IsNil() bool {
return x == nil
}
func (x *Envelope) ConstAddr() Address_Const {
return x.GetAddr()
}
func (x *Envelope) ConstHistory() goconst.Slice[Address_Const] {
return goconst.NewSlice2(x.GetHistory())
}
func (x *Envelope) ConstByTag() goconst.Map[string, Address_Const] {
return goconst.NewMap2(x.GetByTag())
}(For a full end-to-end output including cross-package imports,
*timestamppb.Timestamp fields and Slice / Map over imported
messages, see examples/gen/go/importer/importer.const.pb.go.)
goconst.Slice[T] / goconst.Map[K, V] are defined in this repo's
root package (see goconst.go) and offer:
type DoNotCompare interface {
IsNil() bool // typed-nil-safe presence check; see "Typed-nil pitfall" below
}
type Slice[T any] interface {
DoNotCompare
Len() int
At(i int) T
All() iter.Seq2[int, T] // for i, v := range s.All()
Values() iter.Seq[T] // slices.Collect(s.Values()), slices.Sorted(...)
Zero() T // miss-safe default; see "Miss-safe defaults" below
}
type Map[K comparable, V any] interface {
DoNotCompare
Len() int
Get(k K) (V, bool)
Has(k K) bool
All() iter.Seq2[K, V] // for k, v := range m.All()
Keys() iter.Seq[K] // maps.Keys-shape, pipe into slices.Collect / Sorted
Values() iter.Seq[V] // maps.Values-shape, same idea
Zero() V // miss-safe default; see "Miss-safe defaults" below
}so callers keep O(1) length / indexed / keyed access and the familiar
range-over-func syntax, but lose append / slot assignment at the type
level. Because Values / Keys return iter.Seq[…], the read-only
view plugs straight into stdlib sinks (slices.Collect, slices.Sorted,
maps.Collect, …) and any iter.Seq-aware third-party helper such as
github.com/samber/lo/it — higher-level algorithms (ContainsBy,
Find, MinBy, …) live there rather than on this interface. The two
concrete implementations are provided by the goconst package itself
via
// Scalar / excluded-package elements — pass values through unchanged.
func NewSlice[T any](s []T) Slice[T]
func NewMap[K comparable, V any](m map[K]V) Map[K, V]
// Message elements — project each element via its AsConst() method.
type Constable[T any] interface{ AsConst() T }
func NewSlice2[T any, E Constable[T]](s []E) Slice[T]
func NewMap2[K comparable, V any, E Constable[V]](m map[K]E) Map[K, V]so the plugin only has to emit a one-line companion getter per message / repeated / map field — *Message itself satisfies its _Const interface, so no wrapper type is generated.
Every Message_Const emitted by this plugin is a Go interface, and
everything that returns one — the AsConst() cast, the singular-message
ConstHome() companion, Slice[T_Const].At(i), Map[K, V_Const].Get(k),
Slice.Values() / Map.Values() iterators, Slice.Zero() /
Map.Zero(), and so on — always wraps a concrete *Message pointer into
an interface value. That introduces the classic Go typed-nil trap: a
(*Address)(nil) boxed into an Address_Const is an interface value
whose itab is non-nil and whose data word is nil, so view == nil
evaluates to false — even though semantically there is no Address
behind the view.
This is a language-level fact, not a bug in this library. The library
deliberately leans into it to preserve the nil-safe-read guarantee:
because a typed-nil *Address still answers every scalar / enum /
bytes getter with the zero value (proto3 generates those to be nil-
receiver-safe), callers can chain view.GetStreet() without a
preceding nil check and get "" on a missing field instead of a panic.
Trade-off: view == nil is not the right way to spell "is there
actually a value here?".
To make the correct spelling discoverable at the type level,
goconst.DoNotCompare is a tiny marker interface that exposes an
IsNil() bool method; every Slice, every Map, and every
generated Message_Const embeds it. The generator emits a matching
func (x *Message) IsNil() bool { return x == nil } on every message
pointer, and Slice / Map implementations report whether the
underlying slice / map has no elements.
// ✗ Wrong: typed-nil views are != nil by design.
if p.ConstHome() == nil { ... }
// ✓ Right: IsNil() is evaluated against the concrete dynamic type
// and returns what the caller actually meant.
if p.ConstHome().IsNil() {
// no Home set — fall back, skip, log, ...
} else {
use(p.ConstHome().GetStreet())
}
// For repeated / map fields, IsNil() doubles as an "empty?" predicate —
// a nil slice, an empty slice, a nil map and an empty map all report true.
if !envelope.ConstHistory().IsNil() {
for _, h := range envelope.ConstHistory().All() { ... }
}
// The Map.Get miss sentinel — and the Slice/Map Zero() sentinel for
// Constable projections — are typed-nil views, so IsNil() returns true
// while scalar getters on the view still yield the zero value.
v, ok := m.Get(key)
if v.IsNil() { /* equivalent to !ok for Constable projections */ }
_ = v.GetCity() // always safe, with or without the IsNil() checkBecause the correct spelling (IsNil() instead of == nil) is a rule
no type system can enforce on its own, this repo ships a small
go/analysis linter that flags the wrong spelling at compile time — see
Static check: cmd/nilcompare below.
goconst.NewSlice2 / NewMap2 project every element through its
AsConst() view, so the element type T is an interface (e.g.
Address_Const) rather than a concrete pointer. That has one
unpleasant corner: the Go zero value of an interface is a nil
interface, and calling any method on it panics with the classic
invalid memory address or nil pointer dereference — even though the
same call on a nil *Address would have been safe thanks to protobuf's
nil-receiver-friendly getters.
To keep the "nil receiver is safe" guarantee through the _Const
boundary, Slice and Map both expose a Zero() method and
_Map2.Get leans on it for its miss branch.
Slice[T].Zero() T/Map[K, V].Zero() V- For scalar element / value types: the ordinary Go zero value
(
"",0,false, …). - For
Constableprojections (theNewSlice2/NewMap2flavour): a typed-nil view — an interface value whose itab is the concrete message pointer type and whose data word isnil. The interface comparisonv == nilis thereforefalse, but every scalar / enum /bytesgetter onvsafely returns the zero value instead of panicking.
- For scalar element / value types: the ordinary Go zero value
(
Map[K, V].Get(k)on a miss returns(m.Zero(), false). The second return value is the authoritative presence flag; the first is deliberately chosen so thatv.GetX()is always safe to call, with or without a precedingokcheck.
Two recommended miss-safe patterns fall out of this:
// A) With an iter.Seq-aware helper (e.g. github.com/samber/lo/it):
// pass Zero() as the fallback so the result is always a live view.
addr := loi.FindOrElse(
s.ConstPrevAddresses().Values(),
s.ConstPrevAddresses().Zero(),
func(a Address_Const) bool { return a.GetZip() == "12345" },
)
_ = addr.GetCity() // safe even if no element matches
// B) With a plain Map lookup: trust ok for presence, use v regardless.
if v, ok := m.Get(key); ok {
use(v)
} else {
_ = v.GetCity() // safe: v is a typed-nil view, not a nil interface
}Equivalently, a hand-rolled find over Values() / All() can use
s.Zero() as its loop-local default without importing any third-party
helper — this is exactly what TestPerson_Slice_Zero in
examples/gen/go/nested/nested_const_test.go exercises.
Slice / Map values returned by goconst.NewSlice(...) /
NewSlice2(...) / NewMap(...) / NewMap2(...) all implement
fmt.Stringer. Printing one with fmt.Print*, log.Print*, or %v
produces exactly the same output as printing the raw []T / map[K]V
would — no extra Slice[...] / Map[...] wrapper, no intermediate
slices.Collect / maps.Collect step needed:
s := goconst.NewSlice2(p.GetPrevAddresses())
fmt.Println(s) // == fmt.Println(p.GetPrevAddresses())
m := goconst.NewMap2(p.GetAddressBook())
fmt.Println(m) // == fmt.Println(p.GetAddressBook())For message-element variants (NewSlice2 / NewMap2) the underlying
protobuf messages are printed directly, so you get their rich built-in
prototext-style String() rather than opaque interface addresses.
Key design points:
- Scalars / enums /
byteskeep the stdlib Go type and reuse the concrete*Message's getter — no companion is emitted and the interface lists the plainGetName()name. - Singular message fields switch to the callee's
T_Constview. AConstAddr()companion on*Messageis a one-liner returningx.GetAddr()directly — no explicit.AsConst()hop is emitted, because*Addressitself implementsAddress_Const, so Go's implicit interface conversion performs the cast at zero cost. This also preserves proto3's nil-safe getter semantics: a typed-nil*Addressbecomes a non-nilAddress_Constinterface value whose scalar getters still return zero values instead of panicking. - Repeated fields switch from
[]Ttogoconst.Slice[T_Const](orgoconst.Slice[T]for scalar element types). The companionConstHistory()delegates togoconst.NewSlice2(...)for message elements and togoconst.NewSlice(...)for scalar / excluded-package elements. Type arguments are omitted on purpose — Go 1.23+ constraint type inference recovers both the element type and the projected_Consttype automatically. - Map fields switch from
map[K]Vtogoconst.Map[K, V_Const](orgoconst.Map[K, V]for scalar values), likewise delegating togoconst.NewMap2(...)orgoconst.NewMap(...). oneofis supported; each arm's getter is declared on the interface with the appropriate element type — scalar arms keep their plainGetNote()name, message arms get aConstLocation()companion that returns the callee's_Constview.- Cross-package references use
QualifiedGoIdent, so imports for*_Consttypes from other generated packages are added automatically. - Zero-allocation cast: because
*Messageitself implementsMessage_Const,AsConst()is literallyreturn x. Benchmarks measure ~0.65 ns / 0 allocs for the cast, 0 allocs for singular message-field access, and ~3.5× faster map look-ups versus a wrapper- struct design that allocates a new view on every call.
# buf CLI (the only binary you need on your machine)
go install github.com/bufbuild/buf/cmd/buf@latestYou do not need to go install protoc-gen-go or go build this
plugin:
protocolbuffers/go— the stock Go message generator — runs as a Buf remote plugin pinned by tag (see below); buf fetches and executes it for you.protoc-gen-go-const— this repo's plugin — runs as a local plugin viago run ./cmd/protoc-gen-go-const/main.go, so buf compiles and executes it straight from source on every invocation.
Add this plugin as a second go run local plugin, writing into the same
out directory as protocolbuffers/go so both files land next to each
other:
version: v2
plugins:
# Keep this tag in sync with google.golang.org/protobuf in your go.mod.
- remote: buf.build/protocolbuffers/go:v1.36.11
out: gen/go
opt:
- paths=source_relative
- local: [ "go", "run", "github.com/Kybxd/goconst/cmd/protoc-gen-go-const" ]
out: gen/go
opt:
- paths=source_relative
# Optional, see "exclude_packages" below. Each entry is a doublestar
# glob, so the line below recursively excludes every WKT subpackage.
# - exclude_packages=google.golang.org/protobuf/types/known/**
strategy: allConsumers of the generated code must also have
github.com/Kybxd/goconst in their go.mod (a go mod tidy after the
first buf generate will add it automatically, since *.const.pb.go
imports it).
Then run buf generate as usual. For every foo.proto you will get two
files side by side:
foo.pb.go— standard protobuf Go structs (fromprotocolbuffers/go)foo.const.pb.go—*_Constread-only interface views (from this plugin)
cmd/nilcompare is a standalone go/analysis linter that rejects the
spelling the typed-nil pitfall warns
about at compile time:
// diagnostic + auto-fix: use IsNil() instead
if p.ConstHome() == nil { ... } // ✗ want `... use IsNil() instead`
if envelope.ConstHistory() != nil {} // ✗ want `... use !IsNil() instead`
switch view { case nil: ... } // ✗ want `... use IsNil() in an if`
// fine — concrete pointers behave as you expect
if (*pb.Person)(nil) == nil { ... } // ✓
// fine — type switches ask the dynamic-type question, not the value question
switch v.(type) { case nil: ... } // ✓Matching is nominal rather than structural: an interface is flagged
only if its declaration transitively embeds goconst.DoNotCompare via
an EmbeddedType chain. Interfaces that merely happen to declare an
IsNil() bool method on their own are not flagged, so custom types
that coincidentally share the shape are left alone.
Every reported diagnostic carries a machine-applicable SuggestedFix:
x == nil rewrites to x.IsNil(), x != nil rewrites to
!x.IsNil(). Switch-case uses of case nil: are reported without a
fix (the correct rewrite depends on whether the author wanted an
if/else chain), so they show up as manual TODOs.
# 1. Standalone binary (go vet-compatible)
go install github.com/Kybxd/goconst/cmd/nilcompare@latest
nilcompare ./...
# or, as a vet tool:
go vet -vettool=$(which nilcompare) ./...
# 2. Directly from source — no install needed
go run github.com/Kybxd/goconst/cmd/nilcompare ./...golangci-lint v2 module plugin. Register the plugin package in
.custom-gcl.yml and enable it in .golangci.yml:
# .custom-gcl.yml — build a custom golangci-lint binary that embeds the plugin
version: v2.1.0
name: golangci-lint-nilcompare
destination: ./bin
plugins:
- module: github.com/Kybxd/goconst
import: github.com/Kybxd/goconst/cmd/nilcompare/plugin
version: latest # or pin to a tagged release / pseudo-version# .golangci.yml — enable the plugin like any other linter
linters-settings:
custom:
nilcompare:
type: module
description: forbid comparing DoNotCompare-bearing interfaces to nil
linters:
enable:
- nilcompareThe analyzer is a no-op on packages that do not (transitively) import
github.com/Kybxd/goconst, so enabling it repo-wide is cheap.
Comma/repeat-style flag listing Go import path glob patterns that
should not get *_Const views. Each entry is matched against the
field's owning Go import path with doublestar (gitignore- /
bash globstar-style) semantics:
- a plain path matches exactly, so the legacy "list of Go import paths" usage keeps working unchanged;
*/?match within a single/-separated path segment;- a recursive
**segment matches any number of subpackages, including nested ones — use this to bulk-exclude an entire subtree.
When a field references a message from a matching package, the plugin
keeps the concrete *Type in the enclosing _Const interface (and
therefore emits no Const<Name> companion for it at all, since the
signature already matches the concrete getter):
opt:
# Exact path — the legacy "list of Go import paths" usage.
- exclude_packages=github.com/you/yourrepo/gen/go/proto/external
# Recursive glob — covers every WKT subpackage (timestamppb, durationpb,
# anypb, wrapperspb, structpb, fieldmaskpb, emptypb, …) in one line,
# including any nested subpackages.
- exclude_packages=google.golang.org/protobuf/types/known/**Typical use cases:
- Third-party / vendored protos you don't own and therefore don't run this generator against.
- Well-known types (
google.protobuf.Timestamp,Duration,Any,Wrappers*, …). These are produced by the upstreamprotocolbuffers/goplugin and ship without any*_Const/AsConst(). If you import a WKT in your own proto and leave its Go package out ofexclude_packages, the generated_Constinterface will declare a getter returning e.g.timestamppb.Timestamp_Const— a type that does not exist — and the file will not compile.
Rule of thumb: exclude every WKT package you import. The single
recursive glob google.golang.org/protobuf/types/known/** matches all
of them (.../timestamppb, .../durationpb, .../anypb,
.../wrapperspb, …, including any nested subpackages) and is the
recommended default for projects that touch any WKT.
.
├── goconst.go # runtime Slice / Map interfaces (imported by generated code)
├── cmd/
│ ├── protoc-gen-go-const/ # the protobuf plugin binary (package main)
│ └── nilcompare/ # static-check linter for `view == nil` misuse
│ ├── main.go # singlechecker / go vet-compatible driver
│ ├── analyzer/ # the go/analysis Analyzer + analysistest suite
│ └── plugin/ # golangci-lint v2 module-plugin entrypoint
├── examples/ # hand-crafted protos exercising every branch
│ ├── proto/<leaf>/ # source .proto files
│ ├── gen/go/<leaf>/ # generated .pb.go + .const.pb.go (checked in as golden)
│ ├── buf.yaml
│ └── buf.gen.yaml
├── go.mod
└── README.md # this file
See examples/README.md for what each example proto exercises and how to regenerate them locally.
| Component | Pinned to |
|---|---|
| Go | 1.23.0 (for stdlib iter) |
google.golang.org/protobuf |
v1.36.11 |
buf.build/protocolbuffers/go |
v1.36.11 (kept in sync with the above) |
| proto editions supported | proto2 → edition 2024 (via FEATURE_SUPPORTS_EDITIONS) |
When bumping google.golang.org/protobuf in go.mod, bump the
protocolbuffers/go remote tag in your buf.gen.yaml to the same
version so the generated .pb.go and the ambient runtime stay aligned.