Skip to content

fix(config): say what type a key takes when its value will not parse (MAPCO-11617) - #34

Merged
NivGreenstein merged 5 commits into
developmentfrom
fix/config-type-hints
Sep 23, 2026
Merged

NivGreenstein merged 5 commits into
developmentfrom
fix/config-type-hints

Conversation

@NivGreenstein

@NivGreenstein NivGreenstein commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Jira: MAPCO-11617. Base is development, the integration branch that shigola feature work targets.

What and why

A mistyped config value used to fail in one of three ways. None of them said what the value should have been, and two of them did not name the key:

Config Before After
serve_layer_collections = xx Near line 3 (last key parsed 'maps.serve_layer_collections'): expected value but found "xx" instead toml: line 3 (last key "maps.serve_layer_collections"): expected value but found "xx" instead (maps.serve_layer_collections takes a boolean, true or false)
= "xx" strconv.ParseBool: parsing "xx": invalid syntax toml: line 3 (last key "maps.serve_layer_collections"): "xx" is not a boolean (true or false)
= 1 type %!t(int64=1) could not be converted toml: line 3 (last key "maps.serve_layer_collections"): 1 (int64) is not a boolean (true or false)
= "${NOPE}" environment variable "NOPE" not found toml: line 3 (last key "maps.serve_layer_collections"): environment variable "NOPE" not found

Every failure now names the key and the line. A failure on a bad value also says what the value should have been.

Three separate problems were behind this:

  • No key on decode-stage errors. github.com/BurntSushi/toml v0.4.1 called UnmarshalTOML without the key and returned its error bare. So a quoted or wrong-typed value, or a missing ${VAR}, failed with no key and no line. v1.6.0 wraps that error in a ParseError carrying both. That is the reason for the dependency bump, and the bump alone fixes the key.
  • Wrong or unhelpful messages from env. env.ErrType formatted its value with %t, the boolean verb. It now names the value and what it should have been. Strings that fail to parse as a bool, int, uint or float used to surface strconv's text, which names a Go function rather than anything in the config. They now get the same message.
  • Nothing to say after a syntax error. Unquoted garbage is rejected by the lexer before any type reaches our code, so the most the parser can say is "expected value". config.Parse now takes the key from ParseError.LastKey and walks Config through its toml tags to that field, descending through arrays of tables such as maps.layers.*. It then describes the field from its reflect.Kind. The hint comes from the struct, so it cannot go stale as keys are added.

The hint is added only to syntax errors. A decode-stage error already names the wanted type, so a hint there would just repeat it, and next to a missing ${VAR} it would be irrelevant. Parse tells the two kinds apart after decoding has failed, by checking whether plain TOML rejects the text too. The result wraps the ParseError, so errors.As still finds it.

In dict.go, the two ErrType sites know their key. They now return dict.ErrKeyType, as every other branch in that file already does.

The dependency bump: v0.4.1 → v1.6.0

Only config/ imports the package, so the change has a small footprint. It was checked in three ways:

  • Same parsed configs. Every .toml in the tree (the CITE config and the config/testdata fixtures) and the workspace config.toml were parsed under both versions and compared as JSON. The configs that parse produce identical results. The configs that fail still fail, but now with a key and a line.
  • One ordering difference. v1 decodes in struct order, where v0.4.1 used map order. So a config missing several env vars may report a different one first.
  • Different ParseError format. The message changes from Near line N (last key parsed 'k') to toml: line N (last key "k"). The two tests that pin the format were updated. Nothing in production code reads these errors.
  • govulncheck: 0 vulnerabilities affecting our code, and none in the toml module.

It covers every scalar type, not just booleans

The ticket's examples are all booleans, but the change applies to every key. These cases are pinned by config/config_type_errors_test.go and internal/env/parse_errors_test.go.

Syntax error: the hint added by config.Parse

Config Hint
[[maps.layers]]
min_zoom = abc
(maps.layers.min_zoom takes a non-negative integer)
tile_buffer = xx (tile_buffer takes an integer)
[webserver]
port = xx
(webserver.port takes a string)
bounds = [1.0, x] (maps.bounds takes an array of floating-point numbers)
center = [1.0, x] (maps.center takes an array of floating-point numbers)
[tracing]
enabled = yes
(tracing.enabled takes a boolean, true or false)

Valid TOML of the wrong type: the message after toml: line N (last key "…"):

Value Before (no key, no line) After
tile_buffer = "x" strconv.Atoi: parsing "x": invalid syntax toml: line 1 (last key "tile_buffer"): "x" is not an integer
min_zoom = "x" strconv.ParseUint: parsing "x": invalid syntax toml: line 4 (last key "maps.layers.min_zoom"): "x" is not a non-negative integer
1 for a string type %!t(int64=1) could not be converted … 1 (int64) is not a string
1.5 for an integer type %!t(float64=1.5) could not be converted … 1.5 (float64) is not an integer
-1 for an unsigned key type %!t(int64=-1) could not be converted … -1 (int64) is not a non-negative integer
1 for a float type %!t(int64=1) could not be converted … 1 (int64) is not a floating-point number (e.g. 1.0)
"xx" for a float strconv.ParseFloat: parsing "xx": invalid syntax … "xx" is not a floating-point number (e.g. 1.0)
1 for hostname type %!t(int64=1) could not be converted … 1 (int64) is not a URL string

A value that comes from ${VAR} is reported after substitution. For example, "${FOO}" with FOO=xx on a boolean key gives "xx" is not a boolean.

Deliberately no hint. These cases still name the key and line, but get no type description:

Case Why
hostname = xx env.URL is a struct but is written as a string. Describing it by its kind would call it a table.
a key inside [webserver.headers], [cache], [observer] or [[providers]] These are env.Dict: a table whose keys are not struct fields, and whose values can be anything.
[[maps]]
foo bar
This is an error in the file's structure, not a bad value. LastKey is the table itself, and there is nothing useful to say about an array of tables.
nope = xx The key is unknown, so there is no field to describe.
a future field whose type decodes itself (e.g. a duration written as "5s") Its kind (int64) would describe the wrong thing. Only env's scalar types are trusted, because each accepts exactly its own kind or a ${VAR} string. Pinned by config/type_hint_internal_test.go.

Reading the diff

Read the commits in order:

  1. fix(env): ErrType and the Parse* functions in internal/env.
  2. fix(config): config/type_hint.go (the walker) and its call site in config.Parse.
  3. refactor(config): fixes from the review. The only behaviour change is hints for the narrower int and float widths, which no current field uses.
  4. build(deps): the toml bump and the vendor/ refresh. Most of the diff is here, and it is vendored code. Outside vendor/, only the two tests that pin the ParseError format change.
  5. fix(config): the hint is limited to syntax errors, now that the decoder names the key.

ParseBool, ParseInt, ParseUint and ParseFloat now return a nil pointer instead of a pointer to the zero value when they fail. Every caller checks err first, and all callers are in internal/env.

Parse now reads the whole config with io.ReadAll before decoding. The file is small, and the v0.4.1 decoder already read it all internally.

Verification

  • Table tests pin every message above, including that decode-stage errors name the key and line and do not get the hint, and every case that must stay silent.
  • Full suite on linux/amd64 with RUN_POSTGIS_TESTS=yes, run with both go test -race ./... and CGO_ENABLED=0 go test ./...: all green. The Redis tests were not run.
  • gofmt -s -l on everything outside vendor/: clean. govulncheck: see above.
  • The configs parse identically under both toml versions (see the dependency bump section).
  • /code-review was run on the standards and spec axes, and its structural findings are addressed in commit 3.

Left out on purpose

  • Hint layout. The ticket's example puts the hint on its own indented line. It is appended inline instead, so that a single-line JSON log record stays a single line.
  • Parse*Slice helpers still return strconv errors. Their only callers, in dict.go, replace those errors with dict.ErrKeyType, so the strconv text never reaches an operator.
  • ParseFloat rejects TOML integers. sample_ratio = 1 fails and = 1.0 works. This is existing behaviour. The new message says so (e.g. 1.0) but does not change it.
  • No shigola-docs change. No docs page quotes these messages.

🤖 Generated with Claude Code

NivGreenstein and others added 3 commits September 23, 2026 16:10
…1617)

ErrType formatted its value with %t, the boolean verb, so every wrong-typed
config value read "type %!t(int64=1) could not be converted". It now names
the value and what it should have been: `1 (int64) is not a boolean (true
or false)`.

A string that does not parse as a bool, int, uint or float now returns the
same error instead of strconv's text, which named a Go function rather
than the config.

The two ErrType sites in dict.go know their key, so they return
dict.ErrKeyType like every other branch in that file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(MAPCO-11617)

A value the TOML lexer cannot read, like `serve_layer_collections = xx`,
is rejected before any type reaches our code, so the error could only say
it "expected value". toml.ParseError records the key, though, and the key
names a field of Config. Parse now walks Config through its toml tags to
that field and appends what it takes:

  Near line 3 (last key parsed 'maps.serve_layer_collections'):
  expected value but found "xx" instead
  (maps.serve_layer_collections takes a boolean, true or false)

The hint is read off the field's kind, so it cannot go stale as keys are
added. It is given only for kinds that are unambiguous: env.URL is a
struct written as a string and env.Dict is a table of anything, so
structs, maps and arrays of tables stay silent, as does any type that
decodes itself outside env. The result wraps the ParseError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name the singular/plural hint pair instead of indexing a [2]string, share
the pointer-stripping loop, and describe every int, uint and float width
rather than only the ones Config uses today. Scope the env-var test's
Setenv to its own subtest, and drop a comment that claimed dict.go shares
the want constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coveralls

coveralls commented Sep 23, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 265

Coverage increased (+0.6%) to 56.751%

Details

  • Coverage increased (+0.6%) from the base build.
  • Patch coverage: 9 uncovered changes across 3 files (114 of 123 lines covered, 92.68%).
  • 2 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
config/type_hint.go 84 79 94.05%
internal/env/dict.go 2 0 0.0%
internal/env/parse.go 25 23 92.0%
Total (5 files) 123 114 92.68%

Coverage Regressions

2 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
config/config.go 2 84.13%

Coverage Stats

Coverage Status
Relevant Lines: 10606
Covered Lines: 6019
Line Coverage: 56.75%
Coverage Strength: 121.35 hits per line

💛 - Coveralls

NivGreenstein and others added 2 commits September 23, 2026 16:43
…APCO-11617)

v0.4.1 called UnmarshalTOML without the key it was decoding and returned
its error bare, so a bad quoted or wrong-typed value, or a missing ${VAR},
failed with a message that named no key. v1.6.0 wraps that error in a
ParseError carrying the key and the line:

  toml: line 5 (last key "webserver.headers"): environment variable
  "I_AM_MISSING" not found

Only config/ imports the package. Every .toml in the tree, and the
workspace config, decodes to identical JSON under both versions; configs
that failed still fail, now with a key and a line. v1 decodes in struct
order rather than map order, so a config missing several env vars may
report a different one first.

ParseError's own format changes from "Near line N (last key parsed 'k')"
to "toml: line N (last key \"k\")"; the two tests that pin it follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (MAPCO-11617)

With toml v1.6.0, a value that is valid TOML but wrong for its field
fails with the key, the line, and the env error naming the wanted type:

  toml: line 1 (last key "tile_buffer"): "x" is not an integer

Appending "(tile_buffer takes an integer)" to that repeats it, and on a
missing ${VAR} it is beside the point. The hint now goes only on syntax
errors, the one path where nothing else says what the value should be.
Parse reads the config whole so it can tell them apart: a syntax error is
one plain TOML rejects too, checked only once decoding has failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NivGreenstein
NivGreenstein merged commit cd44d2f into development Sep 23, 2026
15 checks passed
@NivGreenstein
NivGreenstein deleted the fix/config-type-hints branch September 23, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants