A flexible binding library for Go that maps external values (YAML, JSON, CLI args, environment, HTTP request paths, etc.) into struct fields via struct tags.
bind provides a simple way to populate Go structs from various external sources using struct tags. It supports multiple suppliers that can be combined to fill in struct fields from different sources.
go get github.com/ZackarySantana/bindtype Config struct {
Port int `json:"port"`
Host string `yaml:"host"`
DB string `env:"DB_URL"`
}
yaml := []byte(`host: localhost`)
yamlSup, _ := bind.NewYAMLSupplier(bytes.NewReader(yaml))
json := []byte(`{"port":8080}`)
jsonSup, _ := bind.NewJSONSupplier(bytes.NewReader(json))
os.Setenv("DB_URL", "postgres://user:pass@localhost/db")
var cfg Config
bind.Bind(ctx, &cfg, []bind.Supplier{yamlSup, jsonSup, bind.NewEnvSupplier()})If the target struct already has values, they will not be overwritten. This means calls to bind.Bind will only fill in missing values.
Bind outputs debug information using the provided slog.Logger. If not provided, no logging is done.
Example:
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
bind.Bind(ctx, &var, suppliers, bind.WithLogger(logger))Bind only sets fields with a level less than or equal to the provided level. Default is 1. For more information, see Options Struct Tag. This is to support multiple levels of configuration (e.g. the first level is non-auth fields, the second level is auth-required fields).
Example:
var test struct {
Retries int `json:"retries"`
Name string `json:"name" options:"level=1"`
DBURL string `json:"db_url" options:"level=2"`
}
// Only the Retries and Name fields will be set.
bind.Bind(ctx, &var, suppliers)
// DBURL will also be set.
bind.Bind(ctx, &var, suppliers, bind.WithLevel(2))Bind can fill independent fields concurrently when you pass a worker limit greater than 1. Default is 1 (fully sequential). 0 is equivalent to 1 (fully sequential). If set to < 0, unlimited parallelism is used.
Example:
bind.Bind(ctx, &var, suppliers, bind.WithParallel(4))See Parallel binding for how this interacts with SelfSupplier and other late suppliers.
The options struct tag allows you to specify additional options for each field.
Example:
var test struct {
Name string `json:"name" options:"required"`
Age int `json:"age"`
}
jsonSup, _ := bind.NewJSONSupplier(strings.NewReader(`{"age":30}`))
// This will return an error because Name is required but not provided.
err := bind.Bind(ctx, &test, []bind.Supplier{jsonSup})Example:
var test struct {
Retries int `json:"retries"`
Name string `json:"name" options:"level=1"`
DBURL string `json:"db_url" options:"level=2"`
}
jsonSup, _ := bind.NewJSONSupplier(strings.NewReader(`{"retries":3,"name":"Alice","db_url":"postgres://user:pass@localhost/db"}`))
// Only the Retries and Name fields will be set.
bind.Bind(ctx, &test, []bind.Supplier{jsonSup})On fields that use bind.Lazy, bind.Cache, or bind.WeakCache, the options struct tag can include:
deadline=<duration>— maximum time for the entireGetcall (including all retries). The context passed to your suppliers is wrapped with this deadline (for exampleoptions:"deadline=5s").timeout=<duration>— per-attempt timeout: each load attempt uses a nested context with this timeout (for exampleoptions:"timeout=10ms"). Combine withretriesto cap each try separately from the overalldeadline.retries=<n>— how many timesGetruns the loader on error before returning the last error. Default is one attempt (for exampleoptions:"retries=3").
Durations use Go’s time.ParseDuration syntax (for example 500ms, 2m30s).
On bind.Cache and bind.WeakCache fields you can also set:
ttl=<duration>— after this duration from the last successful load, the nextGetwill run the supplier again (for exampleoptions:"ttl=5m"). Other lazy options still apply when the cache misses or after TTL expiry.
Bind supplies a bind.Lazy type that can be used to defer the loading of a value until needed. This is useful for operations that aren't always required, such as fetching data from a database or making an API call.
Example:
type test struct {
Name string `json:"name"`
Age bind.Lazy[int] `database:"age" options:"deadline=10s,timeout=2s,retries=3"`
}This exposes a Get method on the Age field that will call the supplier function to fetch the value when needed. Optional timeout, deadline, and retries on the options tag control timing and retries for Get (see Lazy and cache loader options).
var t test
jsonSup, _ := bind.NewJSONSupplier(strings.NewReader(`{"name":"Alice"}`))
dbSup, _ := bind.NewSelfSupplier(func(ctx context.Context, filter map[string]any) (int, error) {
// Simulate a database call
return 30, nil
}, "database", &t)
err := bind.Bind(ctx, &t, []bind.Supplier{jsonSup, dbSup})
fmt.Println(t.Name) // "Alice"
age, err := t.Age.Get(ctx) // Calls the supplier function to get the age
fmt.Println(age) // 30Golang's type system does not allow for generic types to be used directly in reflection. Therefore, you must register each bind.Lazy type you intend to use with bind.RegisterLazy for that type argument.
type myType struct {
Value string
}
func init() {
bind.RegisterLazy[myType]()
}This registers it for bind.Cache and bind.WeakCache as well.
Bind also provides a bind.Cache type that can be used to cache the result of a supplier function. This is useful for expensive operations that you want to avoid repeating.
The usage is exactly the same as bind.Lazy, but the result is cached after the first call. If you want up-to-date values, use bind.Lazy instead.
bind.WeakCache is the same interface as bind.Cache, but the cached value is held with a weak reference so it can be collected when no longer referenced elsewhere.
See Options Struct Tag for ttl and for lazy-style timeout and retries on cache fields.
When WithParallel is used with a limit greater than 1, or less than 0 (unlimited), bind runs a first pass over the struct with that concurrency, then a second pass that runs sequentially.
Suppliers that implement LateSupplier (Supplier plus BindAfterOtherFields()) only run in the second pass. SelfSupplier implements this interface, so filters that read other fields on the struct see them populated after the first pass. Other suppliers can implement LateSupplier to opt into the same ordering.
When WithParallel is not used, or is 1 or 0, all suppliers run in a single sequential pass as before.
Parses raw JSON into a map[string]json.RawMessage and extracts values by json tags.
func NewJSONSupplier(src io.Reader) (*JSONSupplier, error)Example:
jsonData := `{"name":"Alice","age":30}`
sup, _ := bind.NewJSONSupplier(strings.NewReader(jsonData))
var age int
sup.Fill(ctx, "age", nil, &age)
// Or bind directly to a struct:
var test struct {
Name string `json:"name"`
Age int `json:"age"`
}
bind.Bind(ctx, &test, []bind.Supplier{sup})Parses raw YAML into a map[string]yaml.Node and extracts values by yaml tags.
func NewYAMLSupplier(r io.Reader) (*YAMLSupplier, error)Example:
yamlData := `name: Bob
age: 42`
sup, _ := bind.NewYAMLSupplier(strings.NewReader(yamlData))
var age int
sup.Fill(ctx, "age", nil, &age)
// Or bind directly to a struct:
var test struct {
Name string `yaml:"name"`
Age int `yaml:"age"`
}
bind.Bind(ctx, &test, []bind.Supplier{sup})Looks up environment variables based on env:"..." tags.
Example:
os.Setenv("PORT", "9090")
sup := bind.NewEnvSupplier()
var port int
sup.Fill(ctx, "PORT", nil, &port)
// Or bind directly to a struct:
var test struct {
Port int `env:"PORT"`
}
bind.Bind(ctx, &test, []bind.Supplier{sup})The SelfSupplier is used to populate fields in a struct based on values from the struct itself. This is particularly useful for scenarios where you want to use certain fields as keys to look up additional data from a store or database.
Example:
type User struct {
ID int
Phone string
Name string `test:"id=ID"`
Age int `test2:"num=Phone,other=ID"`
}
u := User{
ID: 9001,
Phone: "970-4133",
}
testSup, _ := bind.NewSelfSupplier(func(ctx context.Context, filter map[string]any) (string, error) {
// filter == map[string]any{"id": 9001}
// Notice how the filter includes the value of ID from the struct
return "found!", nil
}, "test", &u)
test2Sup, _ := bind.NewSelfSupplier(func(ctx context.Context, filter map[string]any) (int, error) {
// filter == map[string]any{"num": "970-4133", "other": 9001}
// Notice how the filter includes the values of Phone and ID from the struct
return 42, nil
}, "test2", &u)
bind.Bind(ctx, &u, []bind.Supplier{testSup, test2Sup})
// u.Name == "found!"
// u.Age == 42SelfSupplier implements LateSupplier. If you use parallel binding (a WithParallel limit greater than 1, or less than 0 for unlimited), self-backed fields are bound in the second pass so dependencies from other fields are available. See Parallel binding.
- PathSupplier: Extracts values from HTTP request paths via
req.PathValue. Usingpath:"..."tags. - QuerySupplier: Extracts values from URL query parameters using
query:"..."tags. - HeaderSupplier: Extracts values from HTTP headers using
header:"..."tags. - FormSupplier: Extracts values from form data using
form:"..."tags. - RequestSuppliers: From a given
*http.Request, creates a PathSupplier, QuerySupplier, HeaderSupplier and, FormSupplier. - FlagSupplier: Binds values from CLI flags using
flag:"..."tags. - FuncSupplier: Uses a user-defined function to supply values based on a given tag.
- FuncStringSupplier: Uses a user-defined function that returns strings to supply values based on a given tag. The strings are then attempted to be converted to the target field type.
Additional suppliers and integrations live under modules/ (for example PostHog feature flags via ph-featureflag tags in modules/posthog).
Run all tests with:
go test ./... ./modules*Benchmark graphs can be seen here.
Run all benchmarks with:
go test -bench=. -benchmem -run=^$ -benchtime=2s -count=4 ./... ./modules/*PRs and issues are welcome! If you add a new supplier, please include:
- Unit tests
- Example usage in the README
- Documentation comments
MIT License. See LICENSE for details.