github creasty/defaults v1.10.0

2 hours ago

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 2707 ns, 39 allocs 2287 ns, 32 allocs
Set, two fields and a SetDefaults 384 ns, 6 allocs, 80 B 474 ns, 6 allocs, 128 B
CanUpdate, scalar 25.0 ns 3.4 ns
CanUpdate, four-field struct 98.0 ns, 1 alloc 26.4 ns, 0 allocs

Documentation

  • The README opens with a quick start instead of a 127-line transcript of example/main.go. The zero-value caveat gets its own section, and the design principles from #61, default:"-", an install line and a pkg.go.dev badge are new. (#82)
  • example/ is replaced by a package example on pkg.go.dev whose output go test checks; the old program's expected output had gone stale. (#91)
  • The call for a new maintainer is gone: the project is maintained again. (#81)
  • Setter's doc comment says when SetDefaults is called (#86), and ErrInvalidType's how to test for it (#93).

Internals

  • reflect lists a promoted method and a declared one alike, so #86 tells them apart by where the method's code lives: the compiler positions the wrapper it generates for a promoted method in <autogenerated>. The Go spec does not promise that. The suite pins both directions, so if a Go release changes it, the tests fail; CI runs the gc toolchain on 1.22, 1.26 and 1.27.
  • defaults.go is split into set.go, mustset.go and canupdate.go, each beside its tests, and internal/fixture is gone. (#82)
  • CI reports a single test-ok check for a branch rule to require, whatever Go versions the matrix holds. (#88)
  • make bench benchmarks Set and CanUpdate. Statement coverage stays at 100%.

Known issues, unchanged by this release

  • time.Duration and a plain int64 share one parser, so default:"1" on a Duration means 1ns and an int64 accepts "1h" (#66). It is the last of v1.9.0's four.
  • A value reached by more than one path gets SetDefaults once per path, as described above.
  • Data with a cycle, such as a parent pointer back up a tree, is walked without end until the stack overflows. Unlike the recursing tag #87 now reports, this is still a fatal error that recover cannot catch.
  • A pointer to a non-struct type whose UnmarshalText took the tag still gets SetDefaults afterwards.
  • Set writes back every struct, slice and map value it visits in a map, changed or not, so calling it while another goroutine reads that map is a data race, and can crash with concurrent map read and map write even when there is nothing to fill.
  • When a default fails on a *scalar, *[]T or *map field, the pointer stays allocated, so a second Set on the same value returns nil and leaves the zero value behind. The *uuid.UUID row above is the same case.

Upgrading

Run your tests. If Set now fails for an array or complex type, that tag was never applied, and the message gives the unmarshaler's reason. If a value that a SetDefaults used to fill comes back zero, find the embedded type it belongs to and forward the call, as shown above. If you inspect errors from a type with its own unmarshaler, the messages and errors.As targets now follow that unmarshaler.

Thanks

To @dima-starosud, whose review on #64 suggested reflect.Value.IsZero — taken up in #83.

Full Changelog: v1.9.0...v1.10.0

Don't miss a new defaults release

NewReleases is sending notifications on new releases.