feat: typed decode and encode errors with the key path

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-17 21:20:47 +02:00
parent 1e3198c8b6
commit 3f41266710
7 changed files with 219 additions and 18 deletions
+5
View File
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `DecodeError` and `EncodeError`: decode and encode failures are wrapped in
typed errors carrying the key path, read with `errors.AsType` instead of
parsing the message text. The rendered messages keep their shape; the only
visible change is that an encode failure on a top-level field no longer
gains a meaningless leading dot in its path.
- `omitzero` and `omitempty` tag options on encode: `toml:"name,omitzero"`
skips a field whose value is the zero value of its type (a type with an
`IsZero() bool` method decides through the method), and
+7 -7
View File
@@ -117,20 +117,20 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
// of the encoder inlining that map's entries.
mv, err := fieldByIndex(dst, schema.embedMaps[0])
if err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
}
continue
}
fv, err := fieldByIndex(dst, field.index)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
if err := d.assign(val, fv); err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
}
return nil
@@ -147,7 +147,7 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
for key, val := range tbl {
elem := reflect.New(elemType).Elem()
if err := d.assign(val, elem); err != nil {
return fmt.Errorf("%s: %w", key, err)
return newDecodeError(key, err)
}
dst.SetMapIndex(reflect.ValueOf(key), elem)
}
@@ -161,7 +161,7 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
for i, item := range items {
if err := d.assign(item, out.Index(i)); err != nil {
return fmt.Errorf("[%d]: %w", i, err)
return newDecodeError(fmt.Sprintf("[%d]", i), err)
}
}
dst.Set(out)
@@ -175,7 +175,7 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
for i, item := range items {
if err := d.assign(item, out.Index(i)); err != nil {
return fmt.Errorf("[%d]: %w", i, err)
return newDecodeError(fmt.Sprintf("[%d]", i), err)
}
}
dst.Set(out)
+48
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"math"
"slices"
"strings"
"testing"
)
@@ -606,3 +607,50 @@ func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) {
t.Fatalf("expected unknown field error, got: %v", err)
}
}
func TestDecodeErrorCarriesPath(t *testing.T) {
type Item struct {
Name string `toml:"name"`
Weight uint8 `toml:"weight"`
}
type Cfg struct {
Tags []string `toml:"tags"`
Items []Item `toml:"items"`
}
var cfg Cfg
err := Unmarshal([]byte("[[items]]\nname = \"a\"\nweight = 300\n"), &cfg)
if err == nil {
t.Fatal("expected an overflow error")
}
de, ok := errors.AsType[*DecodeError](err)
if !ok {
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
}
want := []string{"items", "[0]", "weight"}
if !slices.Equal(de.Path, want) {
t.Fatalf("Path = %v, want %v", de.Path, want)
}
if de.Err == nil || !strings.Contains(de.Err.Error(), "overflows uint8") {
t.Fatalf("Err = %v", de.Err)
}
// The rendered message keeps its shape: segments joined with ": ".
wantMsg := "items: [0]: weight: interpres: integer 300 overflows uint8"
if err.Error() != wantMsg {
t.Fatalf("message = %q, want %q", err.Error(), wantMsg)
}
}
func TestDecodeErrorOnMapDestination(t *testing.T) {
var m map[string]uint8
err := Unmarshal([]byte("count = -1\n"), &m)
if err == nil {
t.Fatal("expected an error")
}
de, ok := errors.AsType[*DecodeError](err)
if !ok {
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
}
if !slices.Equal(de.Path, []string{"count"}) {
t.Fatalf("Path = %v", de.Path)
}
}
+28 -4
View File
@@ -404,6 +404,27 @@ if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
}
```
### `type DecodeError struct{ Path []string; Err error }`
Wraps a decoding failure with the key path at which it happened. `Path` lists
one segment per level from the document root, the outermost key first: a key
contributes its name, an array element its bracketed index, so the path of the
`weight` field in the first item reads `["items", "[0]", "weight"]`. The
rendered message is unchanged by the type; read the fields instead of parsing
the message:
```go
if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
fmt.Println(de.Path, de.Err)
}
```
### `type EncodeError struct{ Path string; Err error }`
Wraps an encoding failure with the key path of the value that failed, in the
document's own notation: `server.ports[2]`. Read it with `errors.AsType` the
same way.
### `type Decoder`
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
@@ -460,11 +481,14 @@ types are produced by `Parse` and accepted by `Marshal`.
The entry points return:
- `*SyntaxError` for a malformed document, with the 1-based line
- a plain error for everything else: a non-pointer decode target, a type
mismatch, an overflow, a marshal policy violation, a cancelled context
- `*DecodeError` for a decoding failure, with the key path in `Path`
- `*EncodeError` for an encoding failure, with the key path in `Path`
- a plain error for the rest: a non-pointer decode target, a cancelled
context, a key that is not valid UTF-8
Decode and encode failures are wrapped with the key path or element index using
`fmt.Errorf`, so `errors.Is` and `errors.AsType` see through them.
Decode and encode failures carry the key path or element index in the typed
wrappers above, so `errors.Is` and `errors.AsType` see through them and the
path reads from a field instead of the message text.
## Notes
+8 -7
View File
@@ -6,6 +6,7 @@ package interpres
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"math"
@@ -287,7 +288,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
if m, ok := v.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err)
return &EncodeError{Path: joinKey(ctx, name), Err: err}
}
v = reflect.ValueOf(mv)
}
@@ -380,13 +381,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
ev := followPtr(v.Index(i))
if !ev.IsValid() {
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
}
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
switch ev.Kind() {
case reflect.Struct:
if isScalarStruct(ev.Type()) {
return fmt.Errorf("interpres: %s.%s[%d]: heterogeneous array contains scalar", ctx, name, i)
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("heterogeneous array contains scalar")}
}
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
return err
@@ -396,7 +397,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
return err
}
default:
return fmt.Errorf("interpres: %s.%s: heterogeneous array, expected table", ctx, name)
return &EncodeError{Path: joinKey(ctx, name), Err: errors.New("heterogeneous array, expected table")}
}
subs[i] = sub
}
@@ -415,13 +416,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
ev := followPtr(v.Index(i))
if !ev.IsValid() {
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
}
if ev.CanInterface() {
if m, ok := ev.Interface().(Marshaler); ok {
mv, err := m.MarshalTOML()
if err != nil {
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
}
ev = reflect.ValueOf(mv)
ev = followPtr(ev)
@@ -429,7 +430,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
}
val, err := normaliseValue(ev)
if err != nil {
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
}
items[i] = val
}
+70
View File
@@ -1160,3 +1160,73 @@ type Custom struct {
}
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
// encodeErrBad is a Marshaler whose MarshalTOML always fails.
type encodeErrBad struct {
msg string
}
func (encodeErrBad) MarshalTOML() (any, error) { return nil, errors.New("bad timestamp") }
func TestEncodeErrorCarriesPath(t *testing.T) {
type Inner struct {
Port encodeErrBad `toml:"port"`
}
type Cfg struct {
Server Inner `toml:"server"`
}
_, err := Marshal(Cfg{Server: Inner{Port: encodeErrBad{}}})
if err == nil {
t.Fatal("expected a marshal error")
}
ee, ok := errors.AsType[*EncodeError](err)
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "server.port" {
t.Fatalf("Path = %q, want %q", ee.Path, "server.port")
}
if ee.Err == nil || ee.Err.Error() != "bad timestamp" {
t.Fatalf("Err = %v", ee.Err)
}
if err.Error() != "interpres: server.port: bad timestamp" {
t.Fatalf("message = %q", err.Error())
}
}
func TestEncodeErrorTopLevelPathHasNoLeadingDot(t *testing.T) {
type Cfg struct {
Port encodeErrBad `toml:"port"`
}
_, err := Marshal(Cfg{})
ee, ok := errors.AsType[*EncodeError](err)
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "port" {
t.Fatalf("Path = %q, want %q", ee.Path, "port")
}
if err.Error() != "interpres: port: bad timestamp" {
t.Fatalf("message = %q", err.Error())
}
}
func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) {
type Item struct {
N int `toml:"n"`
}
cfg := map[string]any{
"items": []any{Item{}, 3},
}
_, err := Marshal(cfg)
if err == nil {
t.Fatal("expected a heterogeneous array error")
}
ee, ok := errors.AsType[*EncodeError](err)
if !ok {
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
}
if ee.Path != "items[0]" {
t.Fatalf("Path = %q, want %q", ee.Path, "items[0]")
}
}
+53
View File
@@ -21,6 +21,7 @@ package interpres
import (
"context"
"errors"
"fmt"
"unicode/utf8"
)
@@ -36,6 +37,58 @@ func (e *SyntaxError) Error() string {
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
}
// A DecodeError wraps a decoding failure with the key path at which it
// happened. Path lists one segment per level from the document root, the
// outermost key first: a key contributes its name and an array element its
// bracketed index, so the path of the weight field in the first item reads
// ["items", "[0]", "weight"]. The rendered message is unchanged by the type;
// read it programmatically with errors.AsType:
//
// if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
// fmt.Println(de.Path, de.Err)
// }
type DecodeError struct {
// Path is the key path from the document root, outermost key first.
Path []string
// Err is the failure at that path.
Err error
}
func (e *DecodeError) Error() string { return e.Path[0] + ": " + e.Err.Error() }
// Unwrap returns the failure the path points at.
func (e *DecodeError) Unwrap() error { return e.Err }
// newDecodeError wraps err with one path segment. The rest of the path comes
// from the DecodeError err already carries, if any: the decoder wraps each
// key and index on its way down, so the innermost wrap holds the deepest
// segments and each outer wrap prepends one.
func newDecodeError(key string, err error) *DecodeError {
path := make([]string, 0, 4)
path = append(path, key)
if de, ok := errors.AsType[*DecodeError](err); ok {
path = append(path, de.Path...)
}
return &DecodeError{Path: path, Err: err}
}
// An EncodeError wraps an encoding failure with the key path of the value
// that failed, in the notation of a TOML document: fields join with dots and
// an array element carries its bracketed index, so the path of the third
// port under server reads "server.ports[2]". The rendered message is
// unchanged by the type; read it programmatically with errors.AsType.
type EncodeError struct {
// Path is the key path of the failing value.
Path string
// Err is the failure at that path.
Err error
}
func (e *EncodeError) Error() string { return "interpres: " + e.Path + ": " + e.Err.Error() }
// Unwrap returns the failure the path points at.
func (e *EncodeError) Unwrap() error { return e.Err }
// Parse decodes a TOML document into a nested map[string]any.
//
// Values are mapped to Go types as follows: strings to string, integers to