Struct tags¶
Two separate mechanisms in this module read struct tags, and they read different tags. Mixing them up is the most common way a field ends up silently at its zero value.
| Mechanism | Reads | Entry points |
|---|---|---|
| Schema validation | config, validate, enum, default, description |
SchemaOf[T], WithStructSchema, ValidateStruct[T] |
| Section decoding | mapstructure, then yaml, then json |
View.Unmarshal, View.UnmarshalKey, Value[T], UnmarshalSection[T], ObserveSection[T] |
One struct may carry both sets.
Validation tags¶
type AppConfig struct {
Server struct {
Port int `config:"server.port" default:"8080"`
Host string `config:"server.host" validate:"required"`
}
Log struct {
Level string `config:"log.level" enum:"debug,info,warn,error"`
}
Internal string `config:"-"` // never validated
}
| Tag | Effect |
|---|---|
config:"a.b" |
Maps the field to a dotted configuration key. A field without this tag contributes nothing to the schema, unless it is a struct, which is walked (see below). |
config:"-" |
Treated identically to an absent tag. On a scalar that means skipped; on a struct field it means walked (see below). |
validate:"required" |
Fails when the key is absent, or is a present-but-empty string. |
enum:"a,b,c" |
Fails when the value is not one of the listed values. |
default:"x" |
Recorded on the field schema. Never injected into the configuration. |
description:"…" |
Recorded on the field schema. Not currently used by any message this module produces. |
validate honours exactly one word¶
The tag is split on commas and every token compared against the literal string required.
Nothing else is honoured, and nothing else is reported. A tag written for another
library, such as:
applies required and silently discards min=1 and oneof=8080 9090. There is no
warning and no error; the constraint simply does not exist.
Anything relational, conditional, or needing a non-zero rather than merely present number belongs in a check you write yourself. See validate your own slice.
required means present, not non-zero¶
A bool set to false and an int set to 0 are configured values, and required accepts
both. Judging by zero-ness would reject an operator who deliberately turned a feature off.
Strings are the one exception: a required string that is present but empty fails, because YAML writes an absent value as the empty string and the two cannot be told apart at this layer.
enum compares rendered strings¶
Each listed value and the configured value are both rendered with %v and compared as
strings. So enum:"1,2" matches the number 1 and the string "1" alike. Values are split
on commas with no whitespace trimming, so enum:"a, b" allows a and " b".
How untagged struct fields become key prefixes¶
A struct field without a config tag is walked rather than skipped, and how it
contributes depends on whether it is embedded:
| Field | Result |
|---|---|
Named struct field Server Inner |
Its lower-cased field name becomes a key prefix: server.…. In a case-sensitive store, the name as written: Server.…. |
Embedded (anonymous) struct Emb |
Merged into the parent with no prefix. |
Non-struct field with no config tag |
Skipped entirely. |
config:"-" does not exclude a struct field
- and "no tag" take the same branch, and that branch walks a struct. So:
type C struct {
Skipped Sec `config:"-"` // still contributes skipped.a
Plain string `config:"-"` // correctly skipped
}
A tagged struct field you want out of the schema has to be left out of the struct, or
validated separately with its own type. - works as expected on scalars only.
The prefix is applied to a child's tag only when that tag contains no dot of its own. So
both of these produce server.port:
Server struct {
Port int `config:"port"` // prefixed: server + port
}
Server struct {
Port int `config:"server.port"` // already dotted: used as written
}
Pick one spelling and stay with it. Nesting compounds: an untagged struct inside an untagged
struct inside the root yields server.deep.a.
What the type check actually compares¶
A schema describes five types: string, int, float64, bool and duration. A field's
schema Type is derived from its Go kind, and everything the schema cannot describe more
precisely is treated as string:
| Go kind | Schema Type |
|---|---|
string |
string |
int, int8, int16, int32, int64 |
int |
time.Duration |
duration |
float32, float64 |
float64 |
bool |
bool |
| everything else | string |
The resolved value is then checked against that type. string is strict: only a Go string
passes. The other four use the same conversions the typed accessors use, so a value that
GetInt would read is an int to the schema:
Schema Type |
Accepts |
|---|---|
string |
A string, and nothing else. |
int |
A number, or text that reads as an integer ("8080"). |
float64 |
A number, or text that reads as one. |
bool |
A bool, or text that reads as one ("true", "1"). A bare number is refused. |
duration |
A duration, a number of nanoseconds, or text such as "5s". |
"Everything else" is broad, and it bites. uint, uint16, []string, map[string]string,
time.Time and any struct all become string, so a field declared:
against an ordinary YAML file produces:
config validation failed:
server.port: expected type string but got int (hint: ensure server.port has a value of type string)
server.hosts: expected type string but got []interface {} (hint: ensure server.hosts has a value of type string)
Use int rather than an unsigned type for a validated numeric field, and leave slices and
maps out of the validated struct. Reading is unaffected. GetUint16 and Value[[]string]
both work; only validation has the narrower type model.
Values that arrive as text are not a problem for the numeric, bool and duration types.
MYTOOL_SERVER_PORT=8080 satisfies an int field, because "8080" reads as an integer. Text
that does not read as the declared type fails: MYTOOL_SERVER_PORT=abc produces
expected type int but got string. The reverse trips the strict string type: a string
field whose YAML value is the bare number 1 fails with expected type string but got int.
Quote the value in the file, or declare the field with the type the value really has.
Unknown keys¶
A configured key the schema does not describe produces a warning, not an error, unless
WithStrictMode() is set. A key that is a parent of a known field is never reported:
server is fine when server.port is in the schema.
Only keys from authored sources are policed: files and compiled-in defaults. Environment variables, flags and layers added at runtime are ambient, so a deployment platform exporting an unrelated prefixed variable cannot stop the application starting. A source of any other kind, such as a remote backend declaring its own, is not policed either.
A schema with no fields is refused¶
NewSchema and SchemaOf[T] return ErrEmptySchema when tag
parsing produced nothing. A schema that constrains nothing would validate everything, which
is worse than having none: you would believe your configuration was checked when it was not.
Decoding tags¶
Section decoding reads a different set, in this order of specificity:
mapstructure:"name"wins outright.mapstructure:"-"skips the field.yaml:"name"is honoured as an alias when it differs from the above.json:"name"is honoured as an alias when it differs from the above.- The lower-cased field name, when no tag names it.
In a store that folds keys, each name is then matched against keys in any case. In a store
built with WithCaseSensitiveKeys, every name is matched exactly: a tag must spell the key as
the source does, and an untagged field AccessKeyID matches only the key AccessKeyID, not
accesskeyid or ACCESSKEYID.
Honouring all three is deliberate. A type shared with an API carries json, one shared with
a YAML document carries yaml, and one written for this library carries mapstructure.
Honouring only the last would leave a field tagged either of the other two at its zero value
silently, with no error.
Embedding¶
| Written as | Meaning |
|---|---|
mapstructure:",squash" |
The embedded struct's fields belong to the parent. |
yaml:",inline" |
The same. yaml and json call it inline; mapstructure calls it squash. |
json:",inline" |
The same. |
An embedded struct with no tag at all is also squashed, matching what yaml and json do by
default.
Types that decode from text without help¶
The decoder is configured with WeaklyTypedInput, so a value written as text reads as the
type the field declares. On top of that, these decode from their ordinary written form:
| Target type | Written as |
|---|---|
time.Duration |
"30s", "1h30m" |
| Any slice, from a single string | "a,b,c", split on commas |
netip.Addr, netip.AddrPort, netip.Prefix |
"192.0.2.1", "192.0.2.1:80", "192.0.2.0/24" |
net.IP, *net.IPNet |
"192.0.2.1", "192.0.2.0/24" |
url.URL |
"https://example.com/x" |
*time.Location |
"Europe/London" |
Anything implementing encoding.TextUnmarshaler |
Whatever that implementation accepts |
The last row is the one that matters for your own code: a domain type or an enum with an
UnmarshalText method decodes without this module having heard of it.
Where decoding reports nothing¶
View.UnmarshalKey returns nil and leaves the target untouched when the path is absent.
Value[T] does the same, yielding the zero value and no error. A caller distinguishing
"absent" from "set to zero" asks View.Has first.
A target that cannot receive values, such as a nil pointer or a non-pointer, returns
ErrInvalidTarget.
Related¶
- Validate configuration: the task guide, with strict mode, attaching a schema to the store, and repairing an invalid configuration
- Use typed sections: decoding a subtree and keeping it current across reloads
- Read configuration values: the accessor surface
- Errors:
ErrInvalidConfig,ErrEmptySchema,ErrInvalidTarget