skip to content

A reflect-based column mapper silently drops struct fields - how do you tell an unexported field from a missing tag?

level: seniorimportance: nice to knowfreq 26%

answer

  1. one StructField member is empty for exported names
  2. a method reads better than the field
  3. presence of a tag is a separate signal
  4. cross the two signals, inspect the corners
  5. the skip is the bug, not the detection

basics

~20 s

Check 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 s

They 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 lines
go
func 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

for a junior

Know that PkgPath is empty for exported fields and non-empty for unexported ones, and that IsExported is the readable way to ask.

for a middle

Explain that export status and tag presence are two independent signals, and walk through what each of the four combinations means for a mapper.

for a senior

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.

for a principal

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