Repository navigation
Releases: creasty/defaults
Release list
v1.11.0
v1.10.0 ended with six known issues. This release fixes three of them — data with a cycle crashed the process, a SetDefaults ran after an unmarshaler had taken the tag, and a failed default left part of itself behind — and half of a fourth: Set no longer writes to a map of slices or maps. The other two, and the rest of the fourth, stay as they are, now documented as intended. The API is unchanged, and so is go.mod.
Behavior changes come first, as before. Two of them can change what a working program does, and neither announces itself: one stops a SetDefaults call, and the other changes what Set leaves behind when it returns an error.
[BREAKING] SetDefaults is skipped behind a pointer once an unmarshaler took the tag (#97)
The README says that when a tag is handed to UnmarshalText, SetDefaults is not called. v1.10.0 made that hold for a struct, behind a pointer or not, and listed the rest as a known issue: a pointer to any other type still got SetDefaults after its unmarshaler took the tag. It no longer does:
type Level int
func (l *Level) UnmarshalText(b []byte) error {
n, err := strconv.Atoi(string(b))
if err != nil {
return err
}
*l = Level(n)
return nil
}
func (l *Level) SetDefaults() { *l += 100 }
type Config struct {
Level *Level `default:"3"` // v1.10.0: 103. v1.11.0: 3.
}a field of that Level, left zero |
v1.10.0 | v1.11.0 |
|---|---|---|
*Level default:"3" |
103 | 3 |
**Level default:"3" |
103 | 3 |
embedded *Level default:"3" |
103 | 3 |
*Level, with UnmarshalJSON in place of UnmarshalText |
103 | 3 |
*Level default:"", which no unmarshaler is offered |
100 | 100 |
*Level default:"0x10", which UnmarshalText rejects and parsing by kind takes |
116 | 116 |
Level default:"3" |
3 | 3 |
Nothing reports the missing call. How to tell whether you are affected: look for a type that is not a struct — a named int, string or slice — with both SetDefaults and UnmarshalText or UnmarshalJSON, behind a pointer a tag allocates. If its SetDefaults adjusted what the unmarshaler produced, that adjustment is gone; make it in the unmarshaler instead. A field of the type itself never got the call, and a pointer the caller allocated is still left alone.
[BREAKING] A failed default no longer leaves part of itself behind (#98)
When a default failed, Set returned the error but kept what it had filled on the way down. The value was then no longer zero, so a second Set could skip the tag and return nil, which v1.10.0 listed as a known issue for *scalar, *[]T and *map fields. Now each value Set found zero on the way to the failing default is put back to zero, whatever part of the default it had taken:
after Set returned the error |
v1.10.0 | v1.11.0 |
|---|---|---|
*int default:"eighty" |
&0, and a second Set returns nil |
nil, and a second Set returns the error again |
*[]string default:"[1]" |
&[], and a second Set returns nil |
nil, and a second Set returns the error again |
a struct field default:"{\"X\": 1, \"Y\": \"two\"}" |
{X:1 Y:0} |
{X:0 Y:0} |
an untagged struct field whose A took 7 before B failed |
{A:7 B:0} |
{A:0 B:0} |
big.Int default:"12x", which its UnmarshalText fills partway before rejecting |
12 |
0 |
[]T default:"[{}]", whose element's default fails |
one element | nil |
Only values that were zero are put back. A pointer, slice or map the caller provided is kept, and the fields of the struct passed to Set keep the defaults they took before the failure: there, A stays 7. The *uuid.UUID row in the v1.10.0 notes changes the same way, to an error with the pointer left nil.
Who is affected: code that ignores Set's error, or recovers from MustSet's panic, and then uses the value. Where it found an allocated pointer it now finds nil, so reading through it panics; where it found part of a default it finds a zero value; and a second Set returns the error again, where it could return nil.
Data with a cycle ends instead of crashing the process (#104, #108)
Set followed wherever the caller's data pointed, so data with a way back to itself, such as a child pointing up to its parent, was walked until the stack overflowed. That is a fatal error rather than a panic: no recover catches it, and the process exits.
type Node struct {
Name string `default:"node"`
Parent *Node
Children []*Node
}
root := &Node{}
root.Children = []*Node{{Parent: root}}
err := defaults.Set(root)
// v1.10.0: fatal error: stack overflow
// v1.11.0: err is nil, both nodes are named "node", and the parent pointer is keptSet now keeps track of the structs, slices and maps on the path it is walking. Where the data leads back to one of them, it goes no further, and the walk already under way finishes that value, so a struct gets no second SetDefaults from its own cycle. Down to 64 values deep the check scans the path and allocates nothing; below that it builds an index of the path and looks values up in it, so deep data costs a few allocations rather than time growing with the square of the depth (#108). See Performance.
A walk that finished in v1.10.0 finishes the same way, with one exception: if a SetDefaults below a repeated value broke the cycle as a side effect, say by clearing the pointer back, v1.10.0 walked that value a second time, and this release does not. The check covers the current path only, so a value that two paths reach, neither through the other, is still walked on both, as described below.
Set no longer writes to a map of slices or maps (#105)
A map value is not addressable, so Set fills a copy and stores it back under its key. It stored back every struct, slice and map value it walked, changed or not, which v1.10.0 listed as a known issue: a goroutine reading the map meanwhile raced with Set, and could crash. A slice or map copy shares its array or table with the value in the map, so storing it back never changed anything, and it no longer happens:
a map[string][]T that Set walks |
v1.10.0 | v1.11.0 |
|---|---|---|
another goroutine reads the map during Set |
data race, which can crash with concurrent map read and map write |
Set only reads the map |
an element's SetDefaults deletes the key its slice is under |
Set stores the key back |
the key stays deleted |
The same holds for a map of maps. A struct value is still stored back, changed or not, so a map of structs still must not be read during Set; that is now documented as intended, below.
An int64 default neither parser takes reports both reasons (#96)
An int64-kinded field is offered to time.ParseDuration and then to strconv.ParseInt, since reflection cannot tell a time.Duration from a plain int64 (#66). When both rejected the tag, only ParseInt's error was reported, so a duration with a unit Go does not know read as bad integer syntax:
| v1.10.0 | v1.11.0 | |
|---|---|---|
time.Duration default:"1d" |
strconv.ParseInt: parsing "1d": invalid syntax |
time: unknown unit "d" in duration "1d"; strconv.ParseInt: parsing "1d": invalid syntax |
int64 default:"abc" |
strconv.ParseInt: parsing "abc": invalid syntax |
time: invalid duration "abc"; strconv.ParseInt: parsing "abc": invalid syntax |
int default:"1d" |
strconv.ParseInt: parsing "1d": invalid syntax |
unchanged |
The field X: invalid default "…": prefix is unchanged, and on a plain int64 the duration half is noise, the cost of the shared parser. A type whose own unmarshaler rejected the tag still reports only that rejection (#90). Both errors are wrapped, so errors.As still reaches the *strconv.NumError and errors.Is still matches strconv.ErrSyntax and strconv.ErrRange, but errors.Unwrap no longer reaches it in one step: errors.Unwrap(err).(*strconv.NumError) stops matching. An empty tag is still no error, and no longer pays for an error message it throws away (#107).
Kept as they are, and now documented
Three behaviors that look wrong were weighed and kept. The README now describes each, and a test marked // QUIRK or // BUG pins it, so changing one later means flipping that test:
- A value reachable by more than one path is filled on each path, with a
SetDefaultscall on each, as v1.10.0's known issues said. #99 filled such a value once per call, at the cost of an allocation on everySetthat entered a value, and was closed in favor of #103 and #104. ASetDefaultsthat is not idempotent applies once per path, and a chain of values each shared by two pointers takes time that doubles with every link. - A struct held as a map value is stored back under its key, changed or not, so do not call
Setwhile another goroutine reads a map of structs it walks. #101 stored a struct back only when it changed, at the cost of an allocation for each struct a tag or setter touched, and was closed in favor of #105. - A pointer the caller allocated to a slice, a map or a pointer is not descended into. What a caller's
*[]T,*map[K]Tor**Tholds gets no defaults, and a default that would fail there is not reported, though a*[]Tor*map[K]Theld as a map value is descended into, as it has been since v1.6.0. #100 descended into all three and was closed in favor of #106, which pins the skip: nobody had asked for the descent, and it would bring new errors and new cycles.
#95 pins more behavior no test covered, marking what looks wrong // QUIRK, and corrects documentation that described behavior Set does not have:
- An integer tag is a Go integer literal, legacy octal included:
default:"0644"is 420, anddefault:"08080"is an error. Slice and map tags are JSON, where a number ...
v1.10.0
v1.9.0 ended with a list of four known issues. This release fixes three of them — #67, #71 and #79 — and two more bugs, #69 and #89, and exports the error Set returns for an argument it cannot fill (#70).
Behavior changes come first again. Two of them can change what a working program does. One is loud: a tag that was never applied is now an error. The other is quiet — a SetDefaults that ran only through method promotion no longer runs — so it comes first.
[BREAKING] SetDefaults runs once per value, and not through promotion (#85, #86)
In v1.9.0 a SetDefaults could run two or three times on the same value. A struct behind a pointer got one call as its fields were filled and another from the pointer (#67). A method promoted from an embedded field ran once for the field and again through the struct embedding it, and once more for each further level of embedding.
A value reached by one path now gets one call, and an embedded field gets exactly the calls a named field of its type would get. For a setter that is idempotent — the usual if c.Port == 0 { c.Port = 8080 } — this first table changes nothing:
SetDefaults calls |
v1.9.0 | v1.10.0 |
|---|---|---|
*T field, tagged default:"{}" or allocated by the caller |
2 | 1 |
element of a []*T |
2 | 1 |
**T field |
2 | 1 |
embedded T |
2 | 1 |
embedded T, two levels deep |
3 | 1 |
embedded *T |
3 | 1 |
That count is per path. A value reached by more than one path — two pointers to one struct, two slices over one array, one map held in two fields — still gets one call from each: two pointers to one struct went from 4 calls to 2, not to 1. See Known issues.
The second table is the one to read twice. These calls happened only through promotion, or after an unmarshaler had already taken the tag, and nothing reports that they are gone:
SetDefaults calls |
v1.9.0 | v1.10.0 |
|---|---|---|
| embedded unexported struct | 1 | 0 |
embedded T tagged default:"-" |
1 | 0 |
embedded interface holding a Setter |
1 | 0 |
embedded T whose UnmarshalText took the tag |
1 | 0 |
*T field, T a struct, whose UnmarshalText took the tag |
1 | 0 |
embedded *T left nil |
called with a nil receiver |
no call |
embedded interface left nil |
panic | no call |
The *T field row is not an embedding. A *T field ran SetDefaults after UnmarshalText where a T field does not; it now agrees with the T field and with the README, which says the tag is handed to UnmarshalText and SetDefaults is not called. That holds only when T is a struct: a pointer to a non-struct type with both methods, such as *Level for type Level int, still gets SetDefaults after UnmarshalText took the tag, as in v1.9.0.
How to tell whether you are affected: look for a type whose SetDefaults you rely on, embedded where it gets no call of its own. A value it used to fill now stays zero:
type base struct{ Timeout time.Duration }
func (b *base) SetDefaults() {
if b.Timeout == 0 {
b.Timeout = 30 * time.Second
}
}
type Client struct {
base // v1.9.0: Timeout is 30s. v1.10.0: Timeout is 0.
}To keep the call, declare SetDefaults on the embedding struct and forward it:
func (c *Client) SetDefaults() { c.base.SetDefaults() }Forward only to a field the second table says gets no call. An exported embedded struct is visited and already gets its call, so forwarding to it runs the setter twice.
Also check any setter that is not idempotent. One that appends, counts or toggles now runs once where it ran two or three times — that is the fix, but it undoes anything that compensated for the repeat.
An embedded non-struct type with a setter, such as type Level int, loses one call too, behind a pointer or not. A struct that declares its own SetDefaults next to an embedded one still gets both calls.
[BREAKING] An array or complex type whose unmarshaler rejects its tag is now an error (#92)
v1.9.0 made an invalid default an error, but missed one path. A type's own UnmarshalText or UnmarshalJSON is offered the tag first, and when it refuses, Set falls back to parsing by kind. Arrays and complex numbers have no parsing by kind, so the refusal went nowhere: the field stayed zero and Set returned nil. For uuid.UUID, a [16]byte, that zero is a nil UUID that looks legitimate. Now:
field ID: invalid default "not-a-uuid": invalid UUID length: 10
| v1.9.0 | v1.10.0 | |
|---|---|---|
uuid.UUID default:"not-a-uuid" |
nil UUID, no error | error |
*uuid.UUID default:"not-a-uuid" |
pointer to a nil UUID, no error | error, pointer still allocated |
a named complex128 whose UnmarshalText rejects the tag |
0, no error |
error |
[3]int default:"[1,2,3]", no unmarshaler |
ignored | ignored |
As with v1.9.0's change, this can stop a program at startup, and only over a tag that never applied. An array type with no unmarshaler has nothing to reject its tag, so the last row is unchanged.
A rejected tag reports the unmarshaler's reason (#90)
When a type's own unmarshaler refused a tag and parsing by kind failed as well, Set reported the second failure — often from encoding/json, a parser the tag was never written for. It now reports the unmarshaler's:
| v1.9.0 | v1.10.0 | |
|---|---|---|
time.Time default:"garbage" |
invalid character 'g' looking for beginning of value |
parsing time "garbage" as "2006-01-02T15:04:05Z07:00": cannot parse "garbage" as "2006" |
slog.Level default:"bogus" |
strconv.ParseInt: parsing "bogus": invalid syntax |
slog: level string "bogus": unknown name |
a struct wrapping time.Duration with UnmarshalText, default:"garbage" |
invalid character 'g' looking for beginning of value |
time: invalid duration "garbage" |
The field X: invalid default "…": prefix is unchanged and the cause is still wrapped with %w, but errors.As now reaches the unmarshaler's error type — a *time.ParseError for time.Time, where it used to be a *json.SyntaxError.
Nothing that succeeded fails now. The fall-back to parsing by kind stays, so slog.Level with default:"4" — which its unmarshalers reject, since they take names only — is still WARN.
One message gets worse. When both of a type's unmarshalers reject a tag, UnmarshalText's reason is the one reported, since it was asked first. For a JSON-quoted time.Time with a bad date, default:"\"2020-13-01T00:00:00Z\"", that is a complaint about the quote, where v1.9.0 said month out of range.
A default that recurses without end is an error, not a crash (#87)
A type that refers to itself through a field whose tag creates another of it recursed until the stack overflowed. A stack overflow is fatal rather than a panic, so no recover could catch it.
| v1.9.0 | v1.10.0 | |
|---|---|---|
Next *Node default:"{}" |
fatal error: stack overflow |
field Next: default "{}" recurses without end |
Children []Tree default:"[{}]" |
fatal error: stack overflow |
field Children: default "[{}]" recurses without end |
Edges map[string]Graph default:"{\"a\":{}}" |
fatal error: stack overflow |
field Edges: default "{\"a\":{}}" recurses without end |
The check is exact, not a depth limit: it stops where the same tag is about to fill a zero value of the same type inside itself, which is the one case that cannot end. A recursive type that does end — at a field with no tag, a tag that creates nothing, or a value already filled in — is walked as before.
A nil argument is an error, not a panic (#84)
| v1.9.0 | v1.10.0 | |
|---|---|---|
Set(nil) |
panic: runtime error: invalid memory address or nil pointer dereference |
ErrInvalidType |
Set((*Config)(nil)) |
panic: reflect: call of reflect.Value.Type on zero Value |
ErrInvalidType |
MustSet with either |
panics as Set does |
panics with ErrInvalidType |
Code that panicked was never working, so nothing regresses. One consequence: if you ignore Set's error, a nil *Config no longer crashes inside Set, so it crashes later, wherever it is first dereferenced.
API: one addition, ErrInvalidType (#93)
The error for an argument that is not a non-nil pointer to a struct is exported, so it can be told apart from a bad tag without matching its text:
if err := defaults.Set(v); errors.Is(err, defaults.ErrInvalidType) {
// v is nil, not a pointer, or not a pointer to a struct
}Its message is still not a struct pointer, so code matching the text keeps working. Test for it with errors.Is rather than ==, as its doc comment says. MustSet panics with the sentinel itself.
Set, MustSet, CanUpdate and Setter are unchanged.
Minimum Go version
go.mod moves from go 1.21 to go 1.22. The zero-value check now uses reflect.Value.IsZero (#83), which until Go 1.22 compared a float's bits and so read -0.0 as non-zero (golang/go#61827). On 1.21 a float field holding -0.0 would have kept it instead of taking its default; on 1.22 and later it takes the default, as in v1.9.0. CI tests 1.22, 1.26 and 1.27.
Go 1.21 has been out of support since 1.23 shipped. A module still on it can stay on v1.9.0; go get of v1.10.0 raises the module's go line to 1.22.
Performance
The zero-value check uses reflect.Value.IsZero rather than reflect.DeepEqual against a fresh zero value (#83), and Set runs it once per field. A struct with a SetDefaults now also pays for the promotion check from #86, which outweighs that saving on a small struct. Measured with make bench plus a struct that has a setter — Go 1.26.5, darwin/arm64, medians of six interleaved runs:
| v1.9.0 | v1.10.0 | |
|---|---|---|
Set, four scalar fields |
711 ns, 9 allocs | 544 ns, 5 allocs |
Set, a struct, a pointer, a slice and a map |
... |
v1.9.0
The first release since v1.8.0 (August 2024), and almost entirely other people's
work: seven pull requests that had been waiting between eight months and two years,
adapted onto a rebuilt test suite.
Behavior changes come first, because there are four of them and one is loud. The
exported API is unchanged, so dependent code keeps compiling; what moved is what the
library does with tags that never worked.
[BREAKING] An invalid default value is now an error (#59, by @marxoffice)
A tag that failed to parse used to be discarded silently: the field kept its zero
value and Set returned nil. It now returns an error naming the field, the tag and
the cause.
field Port: invalid default "abc": strconv.ParseInt: parsing "abc": invalid syntax
This is the one to read twice. If you use the idiom from the README —
if err := defaults.Set(&cfg); err != nil {
log.Fatal(err)
}— then a tag that has been quietly broken for years will now stop your program at
startup. That is the point of the change, not a side effect of it.
How to tell whether you are affected: look for a tag that never actually applied.
| v1.8.0 | v1.9.0 | |
|---|---|---|
int default:"abc" |
0, no error |
error |
int8 default:"999" (overflows the type) |
0, no error |
error |
int32 default:"1h" (only int64 takes durations) |
0, no error |
error |
int default:" 1 " (padded number) |
0, no error |
error |
| a bad tag inside a struct reached through a pointer field | swallowed | error (#68) |
Errors also gained the field name and tag as a prefix, so string matching on errors
needs updating even where an error was already returned: a malformed JSON container
tag reported unexpected end of JSON input and now reports
field V: invalid default "[1,2,3": unexpected end of JSON input. The cause is
wrapped with %w, so errors.Is and errors.As reach through to it — *strconv.NumError,
*json.SyntaxError and *json.UnmarshalTypeError are all still matchable.
[BREAKING] default:"" now allocates (#63, by @5p2O5pe25ouT)
An empty tag used to be indistinguishable from no tag at all. Set reads tags with
StructTag.Lookup now, so default:"" means give me this type's zero value while an
absent tag still means leave this field alone.
| v1.8.0 | v1.9.0 | |
|---|---|---|
*string default:"" |
nil |
pointer to "" |
[]string default:"" |
nil |
empty, non-nil slice |
map[string]int default:"" |
nil |
empty, non-nil map |
Untagged fields are unchanged and still come back nil. The visible consequence is
serialization: null becomes "", [] or {}, so snapshot and golden-file tests
downstream will move.
A padded duration tag now parses (#55, by @boskuv)
time.Duration with default:" 10s " produced 0 and now produces 10s.
The trim belongs to the duration attempt alone, which is why a padded number is now
an error rather than silently zero (above). Named duration types are covered too —
type Timeout time.Duration parses " 10s " — because the trim sits in the int64
branch instead of keying off time.Duration's exact type.
CanUpdate(nil) no longer panics (#64, by @lovewave02)
It used to panic with reflect: call of reflect.Value.Type on zero Value. It returns
true now. Code that panicked was never working, so nothing can regress here.
Minimum Go version
go.mod moves from go 1.14 to go 1.21. CI tests the declared floor plus every
currently supported release: 1.21, 1.26 and 1.27. testify is a test-only dependency —
consumers never build it.
No API change
Set, MustSet, CanUpdate and Setter are unchanged, and nothing was added or
removed. That is deliberate: this release is behavior and tests, not surface.
Documentation
- The README now says what a zero value can and cannot express, and that a pointer is
how you keep the distinction —*boolis how you let an explicitfalsesurvive
default:"true". Six separate reports had run into this: #60, #49, #37, #31, #30 and
#15. (#51, by @fchikwekwe) encoding.TextUnmarshaleris documented as a second way to set defaults, and it takes
precedence overdefaults.Setter. (#56, by @llorllale)
Internals
- The test suite was rewritten. One 771-line file built around a single ~120-field
struct became 15 black-box files exercising only the public API, at 100% statement
coverage with amake covergate that fails the build below it. Today's behavior is
pinned test by test, including the parts that look wrong — those carry aQUIRKor
BUGcomment with a link, so changing one shows up as a deliberate test diff.
(#65, #72) - CI moved to GitHub Actions from a CircleCI config that had stopped running.
(#65) - The map element loop was flattened, and
shouldInitializeFieldnow reads only the
field with the tag hoisted to its caller. Neither changes behavior. (#58, by @gitsang)
Known issues, unchanged by this release
time.Durationand a plainint64share one parser, sodefault:"1"on a Duration
means 1ns and anint64accepts"1h". Not cleanly fixable — reflection cannot tell
a named duration type from a namedint64(#66).- A failing
UnmarshalTextorUnmarshalJSONis discarded, so the error you see can
name the wrong parser (#79). SetDefaultsis called twice on a pointer-to-struct field (#67).- A self-referential type whose tag creates an element recurses until the stack
overflows (#71).
Upgrading
Run your tests. If Set now returns an error, that tag was not working before — the
message names the field and the value. If a nil pointer, slice or map became an empty
one, check whether anything downstream serializes it.
Thanks
To everyone whose pull request sat unreviewed and is in this release anyway:
@lovewave02, @5p2O5pe25ouT, @boskuv, @fchikwekwe, @marxoffice, @gitsang and
@llorllale.
Full Changelog: v1.8.0...v1.9.0