I once spent the better part of an hour convinced a timeout setting was broken. I’d set it in the config file, the tool ignored it, and the code that read it looked perfectly correct. The setting was tiemout. I’d typed it wrong, and not one thing in the entire stack had thought that worth mentioning.
Config loaders are too polite
Most config loaders have the same agreeable flaw: they’ll read whatever’s in the file and quietly ignore anything they weren’t expecting. Put a key the tool doesn’t know about and it sails straight past. No error, no warning, nothing. The loader assumes you meant it, or assumes some other layer will care, and neither turns out to be true.
That politeness costs you in two directions. A key you misspelled is silently dropped, so the setting you thought you’d changed keeps running on its old value. And a key you forgot leaves the field at its zero value, which you then discover at runtime, usually at the least convenient moment, when something downstream divides by a timeout of zero. The file looked fine. It parsed fine. It was just quietly wrong, and nothing was watching for that.
The struct already knows the answer
The thing is, the program already has a complete description of what valid config looks like. It’s the struct you unmarshal into. The field names, the types, which ones matter. That description exists; it’s just not being used to check anything.
go-tool-base’s config package puts it to work. You hand it a tagged struct and it derives a schema from the tags, in pkg/config/schema.go:
// WithStructSchema derives a schema from a tagged Go struct.
// Supported tags: `config:"key" validate:"required" enum:"a,b,c" default:"value"`.
func WithStructSchema(v any) SchemaOption { ... }
So a feature’s config type carries its own rules inline:
type ServerConfig struct {
Host string `config:"host" validate:"required"`
Port int `config:"port" validate:"required"`
LogMode string `config:"log_mode" enum:"text,json"`
}
There’s no second artefact to keep in sync, which is the same instinct go-tool-base leans on for structured AI output: the type is the schema, and the schema is a projection of the type, so the two can’t drift apart because there’s only one of them. Each package describes its own slice of config on its own struct, and NewSchema composes them into the schema the loaded config gets checked against.
Strict mode turns the typo into an error
Deriving the schema is half of it. The half that actually catches tiemout is this one, also from schema.go:
// WithStrictMode treats unknown keys as errors instead of warnings.
func WithStrictMode() SchemaOption { ... }
By default a key the schema doesn’t recognise is a warning: surfaced, but not fatal, which is the right call when a config file might legitimately carry extra keys for tools other than yours. Turn on strict mode and an unknown key becomes an error. tiemout isn’t in the schema, so the tool refuses to start and tells me which key it didn’t recognise, instead of shrugging and using the default for an hour while I lose my mind. The validator walks every key actually present in the file and checks it against the known set, so a typo has nowhere to hide.
What it deliberately doesn’t do
There’s one decision in here I think is worth calling out, because the obvious feature is conspicuously absent. The schema knows each field’s default value. It would be the easiest thing in the world to have validation fill in missing fields from those defaults.
It doesn’t, on purpose. Validation validates. It tells you what’s wrong and what to do about it, and it stops there. Defaults are a separate job, handled by the embedded default config that every feature ships and merges in before validation ever runs. Keeping the two apart means the validator has exactly one responsibility, and the defaults live in one place rather than being half in an embedded file and half injected by a check. A field’s default tag is there for the documentation and the error hint, not as a sneaky second source of values.
Errors you can act on
The output isn’t a bare boolean. Validation returns a result that separates the fatal from the advisory: the missing required field and the wrong type are errors that stop the tool; the unrecognised-but-harmless key is a warning that informs you without blocking. And because each problem carries the offending key by name and a hint about the fix, the message tells you what to change, in the spirit of errors that tell you what to do next.
The short version
A config loader that silently ignores keys it doesn’t recognise will, sooner or later, ignore one you meant. go-tool-base derives a validation schema straight from your tagged config struct, so there’s no separate schema to maintain, and strict mode promotes an unknown key from a quiet shrug to a real error that names the typo. It validates without injecting defaults, because defaults are the embedded config’s job and a validator with one responsibility is easier to trust. Set tiemout now and the tool tells you, which is roughly fifty-nine minutes sooner than I found out.
