skip to content

A reflect-based config loader leaves every setting at its default — how do you diagnose it?

level: seniorimportance: should knowfreq 35%

answer

  1. probe the root before the fields
  2. print CanAddr and CanSet per hop
  3. two falses at the top is decisive
  4. the skip-if-not-settable guard hid it
  5. require a non-nil pointer at the boundary

basics

~20 s

Print CanAddr and CanSet for each Value the walk touches. Both false at the top level means the loader was handed a struct copy instead of a pointer, and its defensive skip-if-not-settable guard turned that into silence. Validate the pointer at the entry point.

solid answer

~50 s

Start at the top of the walk, not at the fields: print `Kind()`, `CanAddr()` and `CanSet()` for the root Value and one known-good field. If the root reports `Struct false false`, the loader received the settings struct by value — `reflect.ValueOf(dst)` of a struct is a copy, so nothing beneath it is settable. The reason it failed *silently* rather than panicking is almost always the defensive guard `if !f.CanSet() { continue }`, which was written to skip unexported fields and quietly swallows the by-value case too. The fix has two parts. At the entry point, require a non-nil pointer to a struct — check `v.Kind() == reflect.Pointer && !v.IsNil()` — and return a clear error otherwise, the way standard-library decoders do. Inside the loop, keep skipping unexported fields, but count or report the settings you were asked to populate and could not, so a missed write is loud rather than a default that looks deliberate.

code

go · 11 lines
go
func Load(dst any) error {
	v := reflect.ValueOf(dst)
	if v.Kind() != reflect.Pointer || v.IsNil() {
		return fmt.Errorf("config: need a non-nil pointer to a struct, got %T", dst)
	}
	v = v.Elem()
	if v.Kind() != reflect.Struct {
		return fmt.Errorf("config: need a pointer to a struct, got pointer to %s", v.Kind())
	}
	return fill(v)
}

go deeper

for a junior

Know the first thing to print: CanAddr and CanSet on the Value at the top of the walk. If both are false, the loader is holding a copy of the struct rather than a pointer to it.

for a middle

Explain the mechanism end to end: an any parameter boxed the struct, addressability was lost at the root, and it propagated to every field the walk visited.

for a senior

Show the operational judgment: validate the pointer once at the API boundary and return a descriptive error, keep the per-field skip for unexported fields, and make an unapplied setting produce a message rather than a plausible default.

for a principal

Own the postmortem's real action item: a reflective API whose misuse compiles is a boundary design defect, and the durable fix is a typed entry point plus a startup assertion, not a smarter walk.

## The shape of the failure A loader walks a settings struct and fills each field from an environment variable or a decoded configuration key. It runs, returns no error, logs nothing — and every setting is still its zero value or its compiled-in default. The service comes up on the wrong port, or with the wrong timeout, and the first signal is production behaviour rather than a stack trace. This is the worst class of reflect bug because reflection moved a type error from compile time to run time, and then a defensive guard moved it from run time to *never*. ## Step one: probe at the root Instrument the walk to print, for each Value it touches, the Kind, `CanAddr()` and `CanSet()`. You only need the first two lines: ``` root: Kind=Struct CanAddr=false CanSet=false field: Kind=Int CanAddr=false CanSet=false ``` Two falses at the root is decisive: reflect is holding a copy. `reflect.ValueOf` takes an `any`, so a struct argument was copied into that interface and has no address; nothing reached beneath it can be addressable either. Contrast the healthy trace, which reads `Kind=Struct CanAddr=true CanSet=true` at the root after `reflect.ValueOf(dst).Elem()`. The usual causes, in order of frequency: 1. The caller passed `cfg` where the loader needed `&cfg`. The signature was `func Load(dst any) error`, so it compiled. 2. The loader dereferenced too early and then stored the struct Value in a local before walking, having called `Elem()` on something that was never a pointer, or having passed the struct into a helper as an `any` again — re-boxing it and losing the address. 3. The value came from a function result or a map lookup that was assigned into an `any` on the way in. ## Step two: explain the silence If the loader had simply called `f.SetString(...)`, it would have panicked immediately and this would be a five-minute bug. Almost every real walk contains a guard like: ``` if !f.CanSet() { continue } ``` That guard is correct and necessary — a struct will contain unexported fields that must be skipped. But it conflates two causes with completely different severities: "this one field is not writable by design" and "nothing in this entire walk is writable". Once the root is a copy, every iteration takes the `continue` branch and the loop finishes having done nothing. ## The fix, in two places **At the API boundary — make it impossible.** Validate once, at the entry point, and return a descriptive error: - reject unless `reflect.ValueOf(dst).Kind() == reflect.Pointer` - reject a nil pointer with `IsNil()`, because `Elem()` on it yields the zero Value and every later check reports false for a second reason - reject unless the dereferenced Kind is `Struct`, if that is what the loader supports This is precisely why the standard library's decoders insist on a non-nil pointer and return an error rather than quietly succeeding: without an address there is nowhere to write, and the caller must be told. **Inside the loop — keep the skip, but account for it.** Continue skipping unexported fields, but distinguish "skipped by design" from "was asked to populate and could not". Counting the fields actually written and comparing against the number of configuration keys matched turns the silent case into an error or a warning with names in it. ## Making the postmortem stick Three changes prevent recurrence, roughly in order of value: 1. **Change the signature.** `func Load(dst *Settings) error` cannot be called wrongly at all; the reflective generality is often not needed. Where it is, a small generic wrapper that takes `*T` and passes `&v` inward restores compile-time safety at the boundary. 2. **Test the failing path, not just the happy one.** A unit test that calls the loader with a value (not a pointer) and asserts a specific error is two lines and would have caught it. 3. **Assert the outcome, not the mechanism.** Have the loader log or return the settings it resolved, so "nothing was applied" is visible at startup rather than inferred from behaviour a week later. ## What not to conclude Do not reach for `recover` around the walk to "handle" settability problems. A panic here means a programming error at the boundary, and swallowing it re-creates the exact silence you are trying to eliminate. And do not remove the `CanSet` guard: unexported fields are real and the guard is right — it just needs a louder companion at the entry point.

  • Why did the loader not panic instead of silently doing nothing?
    Because the walk guarded its writes with `if !f.CanSet() { continue }`. That guard exists to skip unexported fields, and it also swallows the case where the whole walk started from an unaddressable copy. Without it, the first `Set` would have panicked with an unaddressable-value message and the bug would have surfaced instantly.
  • Would wrapping the walk in recover be a reasonable safety net here?
    No. A settability panic signals a caller-side programming error at the API boundary, and recovering from it recreates the silence you are trying to remove. Validate the pointer up front and return an error; reserve recover for genuinely recoverable, data-driven failures, not for contract violations.
  • How do you keep the per-field CanSet skip without hiding a real problem again?
    Separate the two causes. Validate addressability once at the entry point so the root can never be a copy, then let the in-loop skip mean only 'this field is unexported'. Report the names of configuration keys that matched no settable field, so an unwritten setting produces a message rather than a plausible-looking default.
  • What signature change removes the failure mode entirely?
    Taking a concrete pointer — `func Load(dst *Settings) error` — makes the mistake impossible to compile. Where the loader must stay generic, a thin type-parameterised wrapper that accepts `*T` and hands `&v` to the reflective core keeps the compile-time guarantee at the boundary while the walk stays untyped inside.

saying these in an interview costs you the question

  • Starts debugging inside the field loop instead of at the root
  • Adds recover around the walk to make the panic go away
  • Deletes the CanSet guard so unexported fields panic instead
  • Assumes the configuration source is empty without probing settability
  • Says reflect writes are unreliable and rewrites the loader by hand