A reflect-based column mapper silently drops struct fields - how do you tell an unexported field from a missing tag?
answer
- one StructField member is empty for exported names
- a method reads better than the field
- presence of a tag is a separate signal
- cross the two signals, inspect the corners
- the skip is the bug, not the detection
basics
~20 sCheck StructField.IsExported, or equivalently whether PkgPath is empty: a non-empty PkgPath means the field name is unexported. A missing tag is a separate condition, reported by Tag.Lookup returning ok false, and the two deserve different handling.
solid answer
~50 sThey are two distinct signals on the `StructField`. `PkgPath` is empty for an exported field and holds the declaring package's path for an unexported one, and `IsExported()` is the readable spelling of that test — an unexported field still appears in the walk, you just cannot read its value. Whether a `db` tag exists is `Tag.Lookup`'s `ok`. Cross them and you get four cases, and the interesting ones are the mistakes: an unexported field *with* a tag is almost always a lower-cased field someone forgot to fix, and an exported field with *no* tag is either deliberate or a rename that lost its tag. The fix that matters is not the detection, it is the policy: make the walk return an error naming the field instead of skipping it, so a column rename fails the build rather than quietly mapping nothing and leaving that column at its zero value.
code
go · 18 linesfunc columns(t reflect.Type) ([]string, error) {
var cols []string
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() { // same as f.PkgPath != ""
if _, tagged := f.Tag.Lookup("db"); tagged {
return nil, fmt.Errorf("%s.%s: db tag on an unexported field", t, f.Name)
}
continue
}
col, ok := f.Tag.Lookup("db")
if !ok {
return nil, fmt.Errorf("%s.%s: exported field has no db tag", t, f.Name)
}
cols = append(cols, col)
}
return cols, nil
}go deeper
Know that PkgPath is empty for exported fields and non-empty for unexported ones, and that IsExported is the readable way to ask.
Explain that export status and tag presence are two independent signals, and walk through what each of the four combinations means for a mapper.
Argue for failing loudly: name the field in the error, add a completeness test, and keep go vet green so a renamed column cannot silently map to nothing.
Decide where strictness lives across teams - a strict shared walker plus an explicit opt-out marker, or a lenient one plus per-service tests - and who absorbs the churn when a struct gains a field.
## The two signals A `reflect.StructField` carries the export status of the field in `PkgPath`: the documentation defines it as empty for an exported (upper-case) field name, and the qualifying package path for an unexported one. `StructField.IsExported()` is the same test with a name that says what it means, and it is what new code should use. Note what this is *not*: it is not a hand-rolled check on the first letter of `Name`, and it is not derived from the tag. Whether the field carries the tag key your tool cares about is an independent question, answered by `value, ok := f.Tag.Lookup("db")`. ## The four cases, and what each one means | exported | has db tag | what it usually is | |---|---|---| | yes | yes | the normal case: map it | | yes | no | either a field deliberately not persisted, or a rename that lost its tag | | no | no | an internal field: skip it | | no | yes | almost certainly a bug: someone lower-cased the field, or added a tag to a field the mapper can never read | That last row is the one worth wiring an error to. `reflect` will not hand you the value of an unexported field, so a `db` tag on one is dead intent — the author believed the field was being persisted and it never was. ## Why silence is the actual defect A generator that walks a struct and emits one column per tag has a natural shape: for each field, if it can be mapped, emit; otherwise `continue`. That `continue` is where the bug lives. Consider the engineer renaming a database column across the codebase. They change `db:"user_id"` to `db:"account_id"` in twelve structs and fat-finger one of them — a stray space after the colon, or a lower-cased field in a struct that was recently refactored. Everything compiles. Vet may or may not be run. The generator emits a mapping that is missing one column. Reads leave the field at its zero value, writes never mention the column, and the failure surfaces weeks later as rows whose `account_id` is empty. No stack trace points at the tag. The cost asymmetry is the whole argument: skipping saves nobody anything, and erroring costs one build failure at exactly the moment the mistake was made. ## What a hardened walk looks like ```go for i := 0; i < t.NumField(); i++ { f := t.Field(i) if !f.IsExported() { if _, tagged := f.Tag.Lookup("db"); tagged { return fmt.Errorf("%s.%s: db tag on an unexported field", t, f.Name) } continue } col, ok := f.Tag.Lookup("db") if !ok { return fmt.Errorf("%s.%s: exported field has no db tag", t, f.Name) } // ... } ``` Two choices are encoded there and both should be conscious. First, an untagged exported field is an error rather than an implicit skip; if your domain genuinely has fields that must not be persisted, give them an explicit opt-out marker such as `db:"-"` so the intent is written down and the mapper can still be strict about everything else. Second, an unexported field with a tag is a hard error, because there is no reading of that combination in which the author got what they wanted. ## Defence in depth - **`go vet` in CI.** Its structtag analyzer catches tags that `reflect.StructTag.Get` cannot parse — the stray-space class of typo — before anyone runs the generator. It already runs as part of `go test`, so the discipline is simply keeping vet output clean. - **A completeness test.** Walk the type in a test and assert the exact expected set of columns. This catches the case vet cannot see: a perfectly well-formed tag with the wrong value. - **Diff the generated output.** If the generator's output is checked in, a lost column shows up as a deleted line in review, which is a far better place to notice it than production. - **Decide the embedding policy explicitly**, because a struct that gains an embedded type later will otherwise change what the mapper produces without anyone touching the mapper. ## What PkgPath is actually for Beyond the boolean, the string itself is useful in the error message: it names the package that declared the unexported field, which disambiguates when an embedded type from another package contributed it. That said, `IsExported()` is the check; reading `PkgPath` for its content is the rarer, more specialised use.
- How would you catch a mistyped struct tag in CI rather than in production?Two layers. `go vet`'s structtag analyzer catches tags that cannot be parsed the way reflect parses them, and it runs by default under `go test`, so keeping vet clean in the pipeline is free. Vet cannot see a well-formed tag with the wrong value, so add a test that walks the type and asserts the exact expected column set; if the generated mapping is checked in, its diff catches the rest at review.
- Should an exported field with no db tag be an error or a silent skip?Prefer an error, with an explicit opt-out marker such as `db:"-"` for fields that genuinely must not be persisted. Strict-by-default means adding a field to a struct forces a decision at the moment it is added; skip-by-default means the decision is made by omission and nobody sees it. The only real argument for skipping is a mapper applied to types you do not own.
- What does a non-empty PkgPath actually contain, and when is the string itself useful?It is the import path of the package that declared the unexported field name — the qualifier that makes a lower-case name unique across packages. Most code only needs the boolean, which `IsExported()` expresses better. The string earns its place in error messages, where it tells a reader which package contributed a field that arrived through an embedded type from elsewhere.
saying these in an interview costs you the question
- Hand-rolls an upper-case-first-letter check instead of IsExported
- Assumes unexported fields do not appear in the walk at all
- Skips every unmappable field silently and calls it tolerant
- Treats a missing tag and an unexported field as the same case
- Believes a tag on an unexported field still gets read