Generic Config Validation Helper Specification¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-8) (AI drafting assistant)
- Date
- 29 May 2026
- Status
- IMPLEMENTED
- Builds on
- Config Schema Validation Specification (IMPLEMENTED)
Overview¶
The schema validation layer added in the config schema validation
spec works, but the call site it
produces is clunkier than it needs to be. Validating a command's config today
means building a schema, calling Validate, and unwrapping the result by
hand, and because the generated helper is typed against the concrete
*config.Container, the caller also has to type-assert Props.Config (which is
the config.Containable interface) down to the concrete type before it can call
the helper at all.
This specification proposes a small, generic addition to pkg/config,
ValidateStruct[T] (with a supporting SchemaOf[T]), that collapses the whole
dance into one call against the interface. No schema plumbing, no type
assertion, no per-command boilerplate function. It is a pure ergonomics and
safety change: the validation semantics defined by the original spec are
untouched.
Motivation: the current shape¶
A command that validates its config slice currently carries a generated
config.go like this (internal/generator/templates/command.go,
CommandConfigValidation):
func ValidateHelloConfig(cfg *config.Container) error {
schema, err := config.NewSchema(config.WithStructSchema(HelloConfig{}))
if err != nil {
return err
}
result := cfg.Validate(schema)
if !result.Valid() {
return errors.New(result.Error())
}
return nil
}
And to call it, the command has to reach through the interface to the concrete
type (docs/how-to/validate-component-config.md, step 4):
container, ok := p.Config.(*config.Container)
if !ok {
return errors.New("config container required for validation")
}
if err := myfeature.ValidateConfig(container); err != nil {
return err
}
Two problems:
-
The type assertion is unnecessary and leaky.
Validate(schema *Schema) *ValidationResultis already a method on theContainableinterface (pkg/config/container.go). The only reason the cast exists is that the generated helper chose to type its parameter as*config.Container. Asking every caller to assert down to a concrete type, and to invent an error for the "impossible" failure case, leaks an implementation detail and adds noise to every command that validates. -
The schema-building boilerplate repeats per command.
NewSchema+WithStructSchema+Validate+Valid()/Error()is the same five lines in every generatedconfig.go. The type itself is the only thing that varies, which is exactly the shape a generic eliminates.
The project targets Go 1.26, so type parameters are available without reservation.
Proposed API¶
Two additions to pkg/config, both generic over the config struct type T:
// SchemaOf returns a Schema derived from T's struct tags. The result is cached
// per type, so repeated calls for the same T do not re-reflect. Additional
// options (e.g. WithStrictMode) are applied on top and bypass the cache.
func SchemaOf[T any](opts ...SchemaOption) (*Schema, error)
// ValidateStruct validates cfg against the schema derived from T and returns a
// formatted error if any rule fails. It takes the Containable interface, so no
// type assertion to *Container is needed.
func ValidateStruct[T any](cfg Containable, opts ...SchemaOption) error
Reference implementation:
func SchemaOf[T any](opts ...SchemaOption) (*Schema, error) {
// Fast path: no extra options, cache by reflect type.
if len(opts) == 0 {
t := reflect.TypeFor[T]()
if s, ok := schemaCache.Load(t); ok {
return s.(*Schema), nil
}
schema, err := NewSchema(WithStructSchema(*new(T)))
if err != nil {
return nil, err
}
schemaCache.Store(t, schema)
return schema, nil
}
// With caller-supplied options, build fresh (options may change the schema).
return NewSchema(append([]SchemaOption{WithStructSchema(*new(T))}, opts...)...)
}
func ValidateStruct[T any](cfg Containable, opts ...SchemaOption) error {
schema, err := SchemaOf[T](opts...)
if err != nil {
return err
}
if result := cfg.Validate(schema); !result.Valid() {
return errors.New(result.Error())
}
return nil
}
var schemaCache sync.Map // reflect.Type -> *Schema
Both build on the existing, unchanged NewSchema, WithStructSchema,
Containable.Validate, and ValidationResult. Nothing in the validation
engine moves.
Before and after¶
A command that opts into validation goes from this:
// config.go
func ValidateHelloConfig(cfg *config.Container) error {
schema, err := config.NewSchema(config.WithStructSchema(HelloConfig{}))
if err != nil {
return err
}
result := cfg.Validate(schema)
if !result.Valid() {
return errors.New(result.Error())
}
return nil
}
// main.go
container, ok := props.Config.(*config.Container)
if !ok {
return errors.New("config container required for validation")
}
if err := ValidateHelloConfig(container); err != nil {
return err
}
to this:
The HelloConfig struct (the part that actually carries the per-command rules)
stays exactly as it is. Everything else is deleted.
Strict mode and other schema options pass straight through:
Design decisions¶
- Take
Containable, not*Container. This is the core of the fix. The validate method is on the interface; the helper should be too. The type assertion disappears from every call site. - A free function, not a method. Go methods cannot take type parameters, so
ValidateStruct[T]is a package-level function. This reads naturally (config.ValidateStruct[HelloConfig](cfg)) and keepsTat the call site where the struct is known. SchemaOf[T]as a reusable building block. Some callers will want the*Schemaitself (for hot-reload re-validation, or to register with a watcher). Exposing the cached constructor separately avoids forcing them back onto rawNewSchema.- Cache only the option-free path. A schema for a given
Twith no extra options is immutable, so caching it byreflect.Typeis safe and removes per-call reflection. Calls that pass options build fresh, since options can change the resulting schema. - Semantics unchanged. Required/enum/type checks, the unknown-key
warning-vs-strict behaviour, and the
config validation failed:error format are all inherited from the existing layer. This spec adds no new validation behaviour.
Generator and docs changes¶
CommandConfigValidationtemplate (internal/generator/templates/command.go): stop emitting the hand-rolledValidate<Name>Config(*config.Container)function. Emit just the<Name>Configstruct, and have the command callconfig.ValidateStruct[<Name>Config](props.Config)where it currently calls the generated function. (If a named per-command function is still wanted for discoverability, generate a one-line wrapper typed againstContainable:func Validate<Name>Config(cfg config.Containable) error { return config.ValidateStruct[<Name>Config](cfg) }.)- How-to (
docs/how-to/validate-component-config.md): drop thep.Config.(*config.Container)assertion from step 4 and the later examples; show the one-lineValidateStruct[T]call. - Component reference (
docs/components/config.md): documentSchemaOf[T]andValidateStruct[T]alongsideNewSchema/WithStructSchema.
Backwards compatibility and migration¶
- Non-breaking.
NewSchema,WithStructSchema,Containable.Validate, and any existing generatedValidate<Name>Config(*config.Container)continue to compile and behave identically. The new helpers are additive. - Migration is opt-in. Regenerating a command adopts the new form. Hand-written
callers can swap the assertion and schema block for the single
ValidateStruct[T]call at their own pace. - A later release may deprecate generating the concrete-typed helper, once in-tree usages have moved over.
Out of scope¶
- Any change to validation rules, tag vocabulary, or unknown-key handling.
- Auto-wiring validation into a command's lifecycle (it remains an explicit call the author makes; that is a separate question).
- Unmarshalling config into the struct.
Tis used only to derive the schema; values are still read with the typed getters.
Decisions¶
Resolved during implementation:
- Naming:
ValidateStruct[T]andSchemaOf[T].Validate[T]was rejected for sitting awkwardly next to the existingContainer.Validatemethod;ValidateStructreads unambiguously at the call site. - Per-command function kept. The generator continues to emit a named
Validate<Name>Config, now typed againstContainableand delegating toconfig.ValidateStruct[<Name>Config]. This keeps a greppable per-command entry point while removing both the boilerplate and the cast. Callers that prefer to inlineconfig.ValidateStruct[T](props.Config)directly can still do so. - Cache eviction: none. The
sync.Mapkeyed byreflect.Typeis effectively permanent. For the handful of config structs a tool defines this is a non-issue; revisit only if a use case ever generates schemas for unbounded types at runtime.
Implementation notes¶
pkg/config/validate_generic.goaddsSchemaOf[T]andValidateStruct[T], with tests inpkg/config/validate_generic_test.go.internal/generator/templates/command.go(CommandConfigValidation) emits the streamlinedconfig.go.docs/how-to/validate-component-config.mdanddocs/components/config.mdupdated to the cast-free pattern.