A type-safe, collision-free feature flag implementation for Go using context.
- Type-Safe: Uses Go generics for compile-time type safety
- Collision-Free: Each key is uniquely identified by its pointer address
- Context-Safe: Follows Go's context immutability guarantees
- Zero Dependencies: No external dependencies required
- Simple API: Easy to use with minimal boilerplate
go get github.com/mpyw/featureRequires Go 1.21 or later.
package main
import (
"context"
"fmt"
"github.com/mpyw/feature"
)
// Define a feature flag
var EnableNewUI = feature.NewNamedBool("new-ui")
func main() {
ctx := context.Background()
// Enable the feature
ctx = EnableNewUI.WithEnabled(ctx)
// Check if enabled
if EnableNewUI.Enabled(ctx) {
fmt.Println("New UI is enabled!")
}
}package main
import (
"context"
"fmt"
"github.com/mpyw/feature"
)
// Define a feature flag with a custom type
var MaxRetries = feature.New[int]()
func main() {
ctx := context.Background()
// Set a value
ctx = MaxRetries.WithValue(ctx, 5)
// Retrieve the value
retries := MaxRetries.Get(ctx)
fmt.Printf("Max retries: %d\n", retries)
}// Boolean feature flag
var MyFeature = feature.NewBool()
// Boolean feature flag with a debug name
var MyNamedFeature = feature.NewNamedBool("my-feature")
// Feature flag with custom type
var MyValueKey = feature.New[string]()
// Feature flag with custom type and debug name
var MyNamedValueKey = feature.NewNamed[string]("my-key")// Enable a feature
ctx = MyFeature.WithEnabled(ctx)
// Disable a feature
ctx = MyFeature.WithDisabled(ctx)
// Check if enabled
if MyFeature.Enabled(ctx) {
// Feature is enabled
}
// Get raw boolean value
value := MyFeature.Get(ctx)
// Check if the key is set in the context
value, exists := MyFeature.TryGet(ctx)// Set a value
ctx = MyValueKey.WithValue(ctx, "hello")
// Get the value (returns zero value if not set)
value := MyValueKey.Get(ctx)
// Try to get the value (returns value and bool indicating if set)
value, exists := MyValueKey.TryGet(ctx)
if exists {
fmt.Printf("Value: %s\n", value)
}When using context.WithValue directly with string or int keys, collisions are easy:
// β BAD: These keys will collide!
type key string
var userKey key = "user"
var requestKey key = "user" // Same underlying value
ctx = context.WithValue(ctx, userKey, "Alice")
ctx = context.WithValue(ctx, requestKey, "Bob")
// userKey now returns "Bob" instead of "Alice"!You might think: "Just use type contextKey struct{} for each key!"
// π€ Avoids collisions, but has other problems
type userIDKey struct{}
type requestIDKey struct{}
var userID = userIDKey{}
var requestID = requestIDKey{}
ctx = context.WithValue(ctx, userID, 123)
ctx = context.WithValue(ctx, requestID, "abc")
// β Type assertions required, can panic
userIDValue := ctx.Value(userID).(int)
requestIDValue := ctx.Value(requestID).(string)Problems:
- Need a unique type for each key (boilerplate)
- No compile-time type safety for values
- Requires type assertions everywhere (runtime panics possible)
You might think: "What if a library wrapped those keys and handled assertions?"
// π€ Still need to define key types
type userIDKey struct{}
type requestIDKey struct{}
// Library wraps keys with generic type
var UserID = feature.Wrap[int](userIDKey{})
var RequestID = feature.Wrap[string](requestIDKey{})
ctx = UserID.Set(ctx, 123)
value := UserID.Get(ctx) // Library handles assertionProblems:
- Still need to define a unique type for each key (boilerplate)
- Key type and value type are separate - nothing prevents:
// In file A var UserID = feature.Wrap[int](userIDKey{}) // In file B (accidentally) var UserName = feature.Wrap[string](userIDKey{}) // Same key type, different value type!
- No compile-time guarantee that key type β value type mapping is consistent
- Two places to define things: the type definition and the var declaration
// β
BEST: Collision-free, type-safe, ergonomic
var UserID = feature.New[int]()
var RequestID = feature.New[string]()
ctx = UserID.WithValue(ctx, 123)
ctx = RequestID.WithValue(ctx, "abc")
// β
No type assertions, compile-time safety
userIDValue := UserID.Get(ctx) // int
requestIDValue := RequestID.Get(ctx) // string
// β
Rich API
if UserID.IsSet(ctx) {
fmt.Println("User ID is set")
}
value, ok := RequestID.TryGet(ctx)
config := SomeKey.GetOrDefault(ctx, defaultValue)
required := RequiredKey.MustGet(ctx) // Panics with clear message if not setBenefits:
- Pointer identity: Each
varholds a unique pointer, preventing collisions - Type safety: Generics ensure compile-time type checking
- No allocations: Keys are allocated once as package-level variables
- Rich API: Get, TryGet, GetOrDefault, MustGet, IsSet, IsNotSet, DebugValue
- Better debugging: Named keys show up clearly in logs and error messages
- Boolean keys: Special
BoolKeytype with Enabled/Disabled/ExplicitlyDisabled methods - Three-state logic: Distinguish between unset, explicitly true, and explicitly false
A similar idea was proposed to the Go team in 2021:
The proposal by @dsnet (Joe Tsai, a Go team member) suggests:
type Key[Value any] struct { name *string }
func NewKey[Value any](name string) Key[Value] {
return Key[Value]{&name} // Uses argument address for uniqueness
}
func (k Key[V]) WithValue(ctx Context, val V) Context
func (k Key[V]) Value(ctx Context) (V, bool)This package implements essentially the same concept. However, the official proposal has been on hold since November 2021. The only reason given was "On hold for understanding generics better." The Go team has not said anything about it since then.
- Generics were new - The hold came before Go 1.18. Discussion #48287 about how to add generics to existing APIs was closed in April 2023, but it did not decide anything for
context. - Migration path unclear - Whether to deprecate
context.WithValue/context.Valueor keep both APIs - It can live outside the standard library - On the similar #57025, @ianlancetaylor suggested starting as an external package and adopting it later if it sees wide use. That proposal was then retracted. @dsnet also pointed to
tailscale.com/util/ctxkey, without API compatibility guarantees.
This package provides an immediate, production-ready solution in the meantime.
Unlike the proposal's struct-based approach, this package uses the Sealed Interface pattern:
type Key[V any] interface {
WithValue(ctx context.Context, value V) context.Context
Get(ctx context.Context) V
TryGet(ctx context.Context) (V, bool)
// ... other methods
downcast() key[V] // unexported method prevents external implementation
}
type key[V any] struct { // unexported implementation
name string
ident *opaque
}Why this matters:
The struct-based approach has a subtle vulnerability. In Go, you can bypass constructor functions and directly initialize structs with zero values for unexported fields:
// With struct-based design:
type Key[V any] struct { name *string }
// This compiles! Both keys have nil name pointer
badKeyX := Key[int]{}
badKeyY := Key[string]{}
// These will COLLIDE because both use (*string)(nil) as identityNote: (*T)(nil) doesn't panic like nil does - it silently uses the zero value as the key, making collisions hard to detect.
With the Sealed Interface pattern:
- The implementation struct
key[V]is unexported, preventing direct initialization - The interface contains an unexported method
downcast(), preventing external implementations - Users must use
feature.New()orfeature.NewBool()to create keys
Additional benefit: BoolKey can be used anywhere Key[bool] is expected, providing better interoperability than struct embedding would allow.
The internal opaque type that provides pointer identity includes a byte field:
type opaque struct {
_ byte // Prevents address optimization
}Without this, Go's compiler optimization would give all zero-size struct pointers the same address:
type empty struct{}
a := new(empty)
b := new(empty)
fmt.Printf("%p %p\n", a, b) // Same address! Keys would collide.package myapp
import "github.com/mpyw/feature"
// Define keys at package level to ensure single instance
var (
EnableBetaFeature = feature.NewNamedBool("beta-feature")
MaxConcurrency = feature.New[int]()
)// Named keys make debugging easier
var MyFeature = feature.NewNamedBool("my-feature")
fmt.Println(MyFeature)
// Output: my-feature
// Anonymous keys automatically include call site information for debugging
var AnonFeature = feature.NewBool()
fmt.Println(AnonFeature)
// Output: anonymous(/path/to/file.go:42)@0x14000010098// Instead of interface{}, use specific types
var MaxRetries = feature.New[int]()
var UserID = feature.New[string]()
var Config = feature.New[*AppConfig]()Each key holds an internal *opaque pointer that serves as its unique identity. This ensures:
- Each key has a unique identity based on the internal pointer
- Keys can be used as context keys without collisions
- Type safety is maintained through generics
- Even if the key struct is copied, the identity remains the same (copy-safe)
MIT License - see LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request.
- context - Go's official context package
- proposal: context: add generic key and value type #49189 - Go official proposal