gofret

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 31, 2026 License: 0BSD Imports: 11 Imported by: 0

README

gofret

License Coverage CI Go Reference

Convert Go values between shapes: maps into structs, structs into maps, structs into other structs. No dependencies.

go get github.com/rakunlabs/gofret

Requires Go 1.27.

One entry point

There is a single conversion function. The destination type decides what happens, so there is no separate encoder and decoder to keep in step:

cfg, err := gofret.To[Config](data)          // map    -> struct
m,   err := gofret.To[map[string]any](cfg)   // struct -> map
err       = gofret.ToInto(src, &dst)         // into an existing value

Both directions run through the same engine and the same analysis of each struct type, so they cannot disagree about what a tag means. That is also what makes the round trip hold:

m,    _ := gofret.To[map[string]any](cfg)
back, _ := gofret.To[Config](m)
// reflect.DeepEqual(cfg, back) == true

Options

The defaults suit configuration data: keys match loosely and scalars convert leniently, so the "8080" an environment variable hands you reaches an int field and max_retry, max-retry and MaxRetry all find the same one. WithStrictKeys() and WithStrictTypes() turn those off.

Build a Codec when you want options. It is immutable, safe for concurrent use, and caches its analysis of every struct type it sees, so make one and keep it:

c := gofret.New(
    gofret.WithTagFallback("json"),
    gofret.Between(netip.ParseAddr),
    gofret.WithErrorUnused(),
)

err := c.ToInto(data, &cfg)
option effect
WithTag(s) struct tag to read, default cfg
WithTagFallback(s...) tags consulted when the primary one is absent
WithStrictTags() fail on unknown tag options instead of ignoring them
Between(fn) teach a conversion, written as an ordinary function
BetweenOK(fn) the same, for a function that cannot fail
Alias[Named, Base]() lend a named type the conversions of the type it is defined from
WithHooks(h...) conversion hooks, for rules a type pair cannot express
WithWeakTypes() lenient scalar conversions: "42" to 42, 1 to true (default)
WithStrictTypes() only conversions that cannot lose information
WithLooseKeys() match keys ignoring case, -, _ and (default)
WithStrictKeys() match keys exactly
WithKeyNormalizer(fn) replace the fallback key matcher; FoldKey for case-only, nil for exact
WithKeyFunc(fn) derive keys from field names: CamelCase, SnakeCase, KebabCase, PascalCase
WithZeroFields() zero a destination before writing instead of merging into it
WithInlineEmbedded() treat every embedded struct as inline
WithTaggedOnly() ignore fields with no struct tag
WithDerefPointers() treat every pointer field as deref
WithOmitNil() drop nil fields when writing to a map
WithShallow() treat every field as keep
WithErrorUnused() fail when the input holds keys no field claims
WithFailFast() stop at the first error
WithMaxErrors(n) cap how many errors are collected

Struct tags

type Config struct {
    Name    string         `cfg:"name"`
    Secret  string         `cfg:"-"`
    Debug   bool           `cfg:"debug,omitempty"`
    Auth    Auth           `cfg:",inline"`
    Rest    map[string]any `cfg:",remain"`
}
tag to a map to a struct
- skip skip
omitempty drop the zero value
inline merge into the parent read from the parent
remain write the captured keys back collect the unclaimed keys
string render as text parse from text
deref write the pointed-to value
keep pass through unconverted

Unknown options are ignored by default so tags can be shared with packages that have their own option vocabulary. WithStrictTags() rejects them instead, so a typo such as omitemty surfaces the first time the type is used. The check runs once per type, because the analysis is cached. squash is accepted as an alias for inline.

Conversions

Between teaches a codec a type it does not already know. The rule is an ordinary Go function, so functions from other packages register unchanged and no type arguments are needed:

c := gofret.New(
    gofret.Between(netip.ParseAddr),  // string -> netip.Addr
    gofret.Between(uuid.Parse),       // string -> uuid.UUID
)

A registration is looked up by type, not offered every value, so adding more of them does not make every conversion slower. The function is never asked to decline either: it is only ever called with the type it names, so there is no ErrSkip and nothing from gofret appears in its signature.

There are two of them, and which one you want is decided by the function you already have rather than by anything you have to weigh up:

your function use example
func(In) (Out, error) Between(fn) Between(netip.ParseAddr)
func(In) Out BetweenOK(fn) BetweenOK(netip.Addr.String)

Pass the wrong one and it does not compile, and the error names the other.

Neither of them has a direction. Between is not "the reading one" and BetweenOK is not "the writing one" — both register a pair of types, and which pair is whatever the function's own argument and result say:

gofret.Between(netip.ParseAddr)                    // string -> netip.Addr
gofret.Between(func(s Secret) (string, error) {…}) // Secret -> string
gofret.BetweenOK(netip.Addr.String)                // netip.Addr -> string
gofret.BetweenOK(func(s string) Tags {…})          // string -> Tags

Parsing is where the errors are and rendering is where they are not, so a pair of registrations often does come out as Between in and BetweenOK out. That is a fact about parsing, not a rule of the API.

Each call registers one pair, so a type that has to go out and come back gets two:

c := gofret.New(
    gofret.Between(netip.ParseAddr),      // string     -> netip.Addr
    gofret.BetweenOK(netip.Addr.String),  // netip.Addr -> string
)

Two more cover the cases a plain function cannot state:

Alias[Named, Base]() Named is converted exactly like Base
TimeLayout(layouts...) time.Time both ways; reads each layout in turn, writes the first
Interface destinations

An empty interface names no type, so there is nothing to convert towards. A value with a registered text form is written in that form; anything else is carried across as it stands.

That is what makes a map[string]any writable by an encoder and readable back:

m, _ := c.To[map[string]any](cfg)
json.Marshal(m)
// {"addr":"10.0.0.1","d":"1h30m0s","sub":{"n":7}}

Only a registered form is used, never a guessed one, so a type gofret was told how to read but not how to write is left alone. Registering the pair explicitly overrides the rule, because the exact lookup comes first:

gofret.BetweenOK(func(d time.Duration) any { return int64(d) })

Alias covers the named types configuration code is full of. It is deliberately explicit rather than inferred from the underlying type, because sharing an underlying type does not mean sharing a format — type Celsius int64 must not be parsed as a duration:

type Timeout time.Duration

c := gofret.New(gofret.Alias[Timeout, time.Duration]())

Registering a pair twice keeps the last one, so anything gofret provides by default can be replaced.

Understood without being asked

time.Duration converts to and from its text form in both directions, so "1h30m" reaches a time.Duration field and the value comes back out as "1h30m0s". The format is the language's, not one gofret invented, which is the same reason encoding.TextMarshaler, encoding.TextUnmarshaler and fmt.Stringer are honoured.

A number carries no unit, so it keeps the plain integer meaning time.Duration gives it — nanoseconds. "1500" agrees with 1500 under the default weak typing and is rejected by WithStrictTypes, exactly like every other number written as text.

Hooks

A hook is the escape hatch for a rule a type pair cannot express, such as "every type implementing this interface". It is offered every value, so reach for Between first.

There is one form of it, and it is the raw one:

func(ctx gofret.HookCtx) (any, error)

A hook replaces a value before the built-in conversion runs. Return ErrSkip to decline; any other error aborts the conversion and reaches the caller, so a hook can report a real problem instead of quietly falling through.

HookCtx gives you the source and destination types (From, To), the Value, and the parsed Tag of the field being converted. ctx.Data() boxes the value into an any and ctx.Path() builds the dotted location; both are methods so a hook that only inspects types and declines allocates nothing.

Tag is the part a registration cannot reach — the rule is about where the value sits rather than about what type it is:

redact := func(ctx gofret.HookCtx) (any, error) {
    if !ctx.Tag.Has(gofret.OptOmitEmpty) || ctx.From.Kind() != reflect.String {
        return nil, gofret.ErrSkip
    }

    return "****", nil
}

So is matching on an interface, which no list of concrete pairs could name:

if ctx.From == nil || !ctx.From.Implements(redactableType) {
    return nil, gofret.ErrSkip
}

If your rule is "this type becomes that type", it is not one of these. Use Between.

Types that speak for themselves
type ValueEncoder interface{ EncodeValue() (any, error) }
type ValueDecoder interface{ DecodeValue(any) error }

encoding.TextMarshaler, encoding.TextUnmarshaler and fmt.Stringer are honoured out of the box, so a time.Time field reads and writes RFC 3339 and a time.Duration comes out as "1h30m0s" with no configuration at all.

Errors

Every failure is a *gofret.Error carrying the dotted path of the offending value. Failures are collected and joined, so errors.Is and errors.AsType reach all of them.

if ce, ok := errors.AsType[*gofret.Error](err); ok {
    log.Printf("bad value at %s", ce.Path) // "servers[1].port"
}

A conversion reports every failure at once, so the tree usually holds more than one. errors.AsType gives you the first; gofret.Errors gives you all of them:

for _, ce := range gofret.Errors(err) {
    log.Printf("%s: %v", ce.Path, ce.Err)
}

Failures come back in destination field order, not in whatever order Go happened to walk the input map, so the same input always reports the same thing. The same holds for Metadata.

Sentinels: ErrSkip, ErrNotPointer, ErrUnconvertible, ErrUnsupportedType, ErrOverflow, ErrUnusedKeys, ErrInvalidTag.

Nothing panics. A bad input, a bad destination or a bad tag all arrive as an error.

Metadata

var md gofret.Metadata

err := c.ToIntoMeta(data, &cfg, &md)

md.Keys    // paths that were written
md.Unused  // input keys no field claimed, the usual way to warn about typos
md.Unset   // fields no input supplied

Nothing is tracked when you do not ask for it, so the default path stays cheap.

Performance

Analysis of each struct type is cached per codec, tag options are a bitmask, and key matching is precomputed, so no work is repeated per call.

Converting an eight-field config with two nested structs, on a Ryzen 7 5800X:

map to struct

sec/op B/op allocs/op
gofret 4.30µ 2.03Ki 59
struct2 v1.4.0 10.12µ 8.63Ki 142
mapstructure v2.2.1 10.51µ 7.77Ki 137

struct to map

sec/op B/op allocs/op
gofret 5.21µ 2.92Ki 72
struct2 v1.4.0 6.75µ 5.85Ki 66

A configured hook that declines costs about 10% and no allocations, because HookCtx builds the expensive parts only when asked. That cost is per hook per value, though, so it adds up. A registration is looked up instead, and the lookup is a bit test on the kind of the value followed, only for the few that survive it, by a walk down a short list comparing type pointers — nothing is hashed and nothing is allocated. Eight of each, on the map-to-struct benchmark:

sec/op vs. baseline
baseline 5.20µ
8 registrations 5.41µ +4%
8 declining hooks 7.24µ +39%

Offering every value to the table at all, which is what makes time.Duration and the interface rule work without being asked for, costs about 4%:

before after
map to struct 5.41µ 5.64µ
struct to map 6.15µ 6.45µ
round trip 11.31µ 11.78µ

Reuse a Codec; New starts with a cold cache.

License

BSD Zero Clause

Documentation

Overview

Package gofret converts Go values between shapes: maps into structs, structs into maps, and structs into other structs.

One entry point

There is a single conversion function. What happens is decided by the destination type, not by which function you reach for:

cfg, err := gofret.To[Config](data)          // map    -> struct
m, err := gofret.To[map[string]any](cfg)     // struct -> map
err := gofret.ToInto(src, &dst)              // into an existing value

Because both directions run through the same engine and the same analysis of each struct type, they cannot disagree about what a tag means. That is also what makes the round trip property hold: converting a value to a map and back returns the value unchanged.

Configuration

The defaults suit configuration data, which is what gofret is mostly pointed at: keys match loosely, ignoring case, '-', '_' and ' ', and scalars convert leniently, so the "8080" that an environment variable hands you reaches an int field. WithStrictKeys and WithStrictTypes turn those off.

Build a Codec when you want options. A Codec is immutable, safe for concurrent use, and caches its analysis of every struct type it sees, so make one and keep it:

c := gofret.New(
    gofret.WithTagFallback("json"),
    gofret.Between(netip.ParseAddr),
    gofret.WithErrorUnused(),
)

Struct tags

Fields are read from the `cfg` tag by default; see WithTag. The first element is the key name and the rest are options:

Field string `cfg:"name,omitempty"`

-           skip the field, in both directions
omitempty   drop the field when it holds the zero value
inline      merge the field's keys into the parent instead of nesting
remain      collect the keys that match no field, in both directions
string      carry the value as text
deref       write the pointed-to value; nil becomes the zero value
keep        pass the value through instead of converting it

Unknown options are ignored by default so tags can be shared with other packages. WithStrictTags rejects them instead, so a typo such as "omitemty" surfaces the first time the type is used. "squash" is accepted as an alias for "inline". The cost is paid once per type because the analysis is cached.

Conversions

Between teaches a codec a type it does not already know, written as an ordinary Go function. Functions from other packages need no wrapper, and the registration needs no type arguments:

c := gofret.New(
    gofret.Between(netip.ParseAddr),  // string -> netip.Addr
    gofret.Between(uuid.Parse),       // string -> uuid.UUID
)

A registration is looked up by type rather than offered every value, so registering many of them does not make every conversion slower, and the function is never asked to decline: it is only called with the type it names.

Which of Between and BetweenOK you want is decided by the function you already have: the first takes one that can fail, the second one that cannot. Passing the wrong one does not compile, and the error names the other.

Neither has a direction of its own. Both register a pair of types, and which pair is whatever the function's argument and result say, so BetweenOK reads a value in as readily as it writes one out. Parsing is where the errors are and rendering is where they are not, so a pair of registrations often does come out as Between in and BetweenOK out, but that is a fact about parsing rather than a rule.

Each call registers one pair, so a type that has to go out and come back gets two.

Alias lends a named type such as `type Timeout time.Duration` the conversions registered for the type it is defined from, and TimeLayout registers both directions of a time.Time written in some layout of its own. Those two exist because neither states something a plain function could.

An empty interface, as found inside a map[string]any, names no type, so there is nothing to convert towards. A value with a registered text form is written in that form and anything else is carried across as it stands, which is what makes such a map writable by an encoder and readable back.

time.Duration is understood without being asked, in both directions, because its text form is one the language defines rather than one gofret invented. The same holds for encoding.TextMarshaler, encoding.TextUnmarshaler and fmt.Stringer. A number carries no unit, so it keeps the plain integer meaning time.Duration gives it.

Hooks

A hook is the escape hatch for a rule a pair of types cannot express, such as "every type implementing this interface" or "every field carrying this tag option". It is offered every value, so reach for Between first.

A Hook replaces a value before the built-in conversion runs. Return ErrSkip to decline and let the next hook, or the built-in conversion, take over. Any other error aborts the conversion and reaches the caller, so a hook can report a real failure instead of silently declining:

redact := func(ctx gofret.HookCtx) (any, error) {
    if !ctx.Tag.Has(gofret.OptOmitEmpty) {
        return nil, gofret.ErrSkip
    }

    return "****", nil
}

c := gofret.New(gofret.WithHooks(redact))

The tag is the part a registration cannot reach, because the rule is about where the value sits rather than what type it is. Matching on an interface is the other, since no list of concrete pairs could name one. A rule of the form "this type becomes that type" is neither; use Between.

A type can also speak for itself by implementing ValueEncoder or ValueDecoder, and encoding.TextMarshaler, encoding.TextUnmarshaler and fmt.Stringer are honoured out of the box.

Errors

Every failure is an Error carrying the dotted path of the value that caused it. Failures are collected and joined, so errors.Is and errors.AsType reach all of them.

errors.AsType picks out the first one, and Errors lists them all:

if ce, ok := errors.AsType[*gofret.Error](err); ok {
    log.Printf("bad value at %s", ce.Path)
}

for _, ce := range gofret.Errors(err) {
    log.Printf("%s: %v", ce.Path, ce.Err)
}

Failures are reported in destination field order rather than in the order Go happened to walk the input map, so the same input always reports the same thing. The same holds for Metadata.

See WithFailFast and WithMaxErrors to stop earlier, and Codec.ToIntoMeta to find out which keys went unused.

Example
package main

import (
	"fmt"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Name    string `cfg:"name"`
		Retries int    `cfg:"retries"`
	}

	cfg, err := gofret.To[Config](map[string]any{
		"name":    "service",
		"retries": 3,
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("%s %d\n", cfg.Name, cfg.Retries)
}
Output:
service 3
Example (Error)
package main

import (
	"errors"
	"fmt"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Server struct {
		Port int `cfg:"port"`
	}

	type Config struct {
		Servers []Server `cfg:"servers"`
	}

	_, err := gofret.To[Config](map[string]any{
		"servers": []any{
			map[string]any{"port": 80},
			map[string]any{"port": "nope"},
		},
	})

	if ce, ok := errors.AsType[*gofret.Error](err); ok {
		fmt.Println("path:", ce.Path)
	}

	fmt.Println("unconvertible:", errors.Is(err, gofret.ErrUnconvertible))
}
Output:
path: servers[1].port
unconvertible: true
Example (Hook)

A hook is offered every value, so it can match on things a pair of types cannot express. Here the rule is about the struct tag rather than the type.

package main

import (
	"fmt"
	"reflect"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		User     string `cfg:"user"`
		Password string `cfg:"password,omitempty"`
	}

	redact := gofret.Hook(func(ctx gofret.HookCtx) (any, error) {
		if !ctx.Tag.Has(gofret.OptOmitEmpty) || ctx.From.Kind() != reflect.String {
			return nil, gofret.ErrSkip
		}

		return "****", nil
	})

	c := gofret.New(gofret.WithHooks(redact))

	m, err := c.To[map[string]string](Config{User: "ray", Password: "hunter2"})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(m["user"], m["password"])
}
Output:
ray ****
Example (HookError)

A hook declines with ErrSkip and fails with anything else, so a genuine problem is reported instead of quietly falling through.

package main

import (
	"fmt"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Port int `cfg:"port"`
	}

	c := gofret.New(gofret.WithHooks(func(ctx gofret.HookCtx) (any, error) {
		n, ok := ctx.Data().(int)
		if !ok {
			return nil, gofret.ErrSkip
		}

		if n < 1024 {
			return nil, fmt.Errorf("port %d is reserved", n)
		}

		return n, nil
	}))

	_, err := c.To[Config](map[string]any{"port": 80})

	fmt.Println(err)
}
Output:
gofret: port: cannot convert int to int: port 80 is reserved
Example (Inline)

The `inline` option flattens a nested struct into its parent, in both directions.

package main

import (
	"fmt"
	"sort"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Auth struct {
		User string `cfg:"user"`
		Pass string `cfg:"pass"`
	}

	type Config struct {
		Host string `cfg:"host"`
		Auth Auth   `cfg:",inline"`
	}

	m, err := gofret.To[map[string]any](Config{
		Host: "db",
		Auth: Auth{User: "u", Pass: "p"},
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	printMap(m)
}

// printMap renders a map with its keys in order, so the example output is
// stable.
func printMap(m map[string]any) {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}

	sort.Strings(keys)

	for _, k := range keys {
		fmt.Printf("%s: %v\n", k, m[k])
	}
}
Output:
host: db
pass: p
user: u
Example (Metadata)
package main

import (
	"fmt"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Name    string `cfg:"name"`
		Missing string `cfg:"missing"`
	}

	var (
		md  gofret.Metadata
		cfg Config
	)

	err := gofret.New().ToIntoMeta(map[string]any{
		"name": "service",
		"typo": true,
	}, &cfg, &md)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println("used:  ", md.Keys)
	fmt.Println("unused:", md.Unused)
	fmt.Println("unset: ", md.Unset)
}
Output:
used:   [name]
unused: [typo]
unset:  [missing]
Example (Options)
package main

import (
	"fmt"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		MaxRetry int    `cfg:"maxRetry"`
		Name     string `json:"name"`
	}

	// The `cfg` tag is read by default; a fallback picks up the `json` tag on
	// fields that carry no `cfg` one.
	c := gofret.New(gofret.WithTagFallback("json"))

	// Weak typing accepts the string, and loose keys match "max_retry"
	// against "maxRetry". Both are on by default.
	cfg, err := c.To[Config](map[string]any{
		"max_retry": "5",
		"NAME":      "service",
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("%d %s\n", cfg.MaxRetry, cfg.Name)
}
Output:
5 service
Example (Remain)

The `remain` option collects the keys no field claimed, and writes them back out again, which is what keeps a round trip lossless.

package main

import (
	"fmt"
	"sort"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Name string         `cfg:"name"`
		Rest map[string]any `cfg:",remain"`
	}

	cfg, err := gofret.To[Config](map[string]any{
		"name":    "service",
		"unknown": "kept",
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	back, err := gofret.To[map[string]any](cfg)
	if err != nil {
		fmt.Println(err)

		return
	}

	printMap(back)
}

// printMap renders a map with its keys in order, so the example output is
// stable.
func printMap(m map[string]any) {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}

	sort.Strings(keys)

	for _, k := range keys {
		fmt.Printf("%s: %v\n", k, m[k])
	}
}
Output:
name: service
unknown: kept
Example (ToMap)

The destination type decides the direction, so writing a struct out is the same call with a different type argument.

package main

import (
	"fmt"
	"sort"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Server struct {
		Host string `cfg:"host"`
		Port int    `cfg:"port"`
	}

	type Config struct {
		Name    string `cfg:"name"`
		Primary Server `cfg:"primary"`
	}

	m, err := gofret.To[map[string]any](Config{
		Name:    "service",
		Primary: Server{Host: "localhost", Port: 8080},
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	printMap(m)
}

// printMap renders a map with its keys in order, so the example output is
// stable.
func printMap(m map[string]any) {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}

	sort.Strings(keys)

	for _, k := range keys {
		fmt.Printf("%s: %v\n", k, m[k])
	}
}
Output:
name: service
primary: map[host:localhost port:8080]
Example (ValueEncoder)

A type can decide for itself how it is written and read.

package main

import (
	"fmt"
	"sort"
	"strings"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Name csv `cfg:"name"`
	}

	m, err := gofret.To[map[string]any](Config{Name: csv{"a", "b"}})
	if err != nil {
		fmt.Println(err)

		return
	}

	printMap(m)

	back, err := gofret.To[Config](m)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(back.Name)
}

type csv []string

func (c csv) EncodeValue() (any, error) { return strings.Join(c, ","), nil }

func (c *csv) DecodeValue(v any) error {
	s, ok := v.(string)
	if !ok {
		return fmt.Errorf("want a string, got %T", v)
	}

	*c = strings.Split(s, ",")

	return nil
}

// printMap renders a map with its keys in order, so the example output is
// stable.
func printMap(m map[string]any) {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}

	sort.Strings(keys)

	for _, k := range keys {
		fmt.Printf("%s: %v\n", k, m[k])
	}
}
Output:
name: a,b
[a b]

Index

Examples

Constants

View Source
const DefaultTag = "cfg"

DefaultTag is the struct tag gofret reads when WithTag is not given.

Variables

View Source
var (
	// ErrSkip is returned by a Hook that does not handle the value. It means
	// "not mine, keep going" and is never reported to the caller. Any other
	// error from a hook aborts the conversion.
	ErrSkip = errors.New("gofret: skip hook")

	// ErrNotPointer is returned by ToInto when out is not a non-nil pointer.
	ErrNotPointer = errors.New("gofret: output must be a non-nil pointer")

	// ErrUnconvertible means the source value cannot be represented in the
	// destination type.
	ErrUnconvertible = errors.New("gofret: unconvertible value")

	// ErrUnsupportedType means the destination type is one gofret cannot
	// write to, such as a channel.
	ErrUnsupportedType = errors.New("gofret: unsupported type")

	// ErrOverflow means the source value does not fit in the destination.
	ErrOverflow = errors.New("gofret: value overflows destination type")

	// ErrUnusedKeys is reported when WithErrorUnused is set and the input
	// contains keys that match no destination field.
	ErrUnusedKeys = errors.New("gofret: unused keys in input")

	// ErrInvalidTag means a struct tag carries an unknown option.
	ErrInvalidTag = errors.New("gofret: invalid struct tag")
)

Sentinel errors. Every error produced by gofret wraps one of these, so callers can branch with errors.Is instead of matching on strings.

Functions

func CamelCase

func CamelCase(s string) string

CamelCase renders the name in lowerCamelCase: "MaxRetry" becomes "maxRetry", "ID" becomes "id" and "HTTPServer" becomes "httpServer".

func FoldKey

func FoldKey(s string) string

FoldKey lowercases the key, which makes lookups case-insensitive while still telling separators apart. Pass it to WithKeyNormalizer to soften the default LooseKey matching without turning it off entirely.

func KebabCase

func KebabCase(s string) string

KebabCase renders the name in kebab-case: "MaxRetry" becomes "max-retry".

func LooseKey

func LooseKey(s string) string

LooseKey lowercases the key and removes '-', '_' and ' ', so "MaxRetry", "max_retry", "max-retry" and "max retry" all match. It is the default matcher.

func LowerCase

func LowerCase(s string) string

LowerCase lowercases the whole name without inserting separators.

func PascalCase

func PascalCase(s string) string

PascalCase renders the name in UpperCamelCase: "max_retry" becomes "MaxRetry".

func SnakeCase

func SnakeCase(s string) string

SnakeCase renders the name in snake_case: "MaxRetry" becomes "max_retry" and "HTTPServer" becomes "http_server".

func To

func To[T any](in any) (T, error)

To converts in to a value of type T using the default configuration.

Build a Codec with New when you need options or want the type cache to be reused across calls.

func ToInto

func ToInto(in, out any) error

ToInto converts in and writes the result through out, which must be a non-nil pointer, using the default configuration.

Types

type Codec

type Codec struct {
	// contains filtered or unexported fields
}

Codec converts values between shapes according to a fixed configuration.

A Codec is immutable once built and safe for concurrent use. It caches the analysis of every struct type it sees, so reuse one instead of calling New on every conversion.

func New

func New(opts ...Option) *Codec

New builds a Codec from the given options.

func (*Codec) To

func (c *Codec) To[T any](in any) (T, error)

To converts in to a value of type T.

T decides what happens: converting to a struct reads a map, converting to map[string]any writes one, and converting between two structs copies field by field.

cfg, err := c.To[Config](data)              // map    -> struct
m, err := c.To[map[string]any](cfg)         // struct -> map

On error the returned value holds whatever was converted before the failure; treat it as unusable unless err is nil.

func (*Codec) ToInto

func (c *Codec) ToInto(in, out any) error

ToInto converts in and writes the result through out, which must be a non-nil pointer.

func (*Codec) ToIntoMeta

func (c *Codec) ToIntoMeta(in, out any, md *Metadata) error

ToIntoMeta is ToInto and additionally records what happened into md, which may be nil.

type Error

type Error struct {
	// Path is the dotted location of the value, for example
	// "database.hosts[2].port". It is empty at the root.
	Path string
	// From is the type of the source value. It may be nil for a nil source.
	From reflect.Type
	// To is the type of the destination.
	To reflect.Type
	// Err is the underlying cause.
	Err error
}

Error describes a single conversion failure and where it happened.

Errors are collected rather than aborting on the first failure (unless WithFailFast is set) and joined with errors.Join, so errors.Is and errors.As reach every one of them.

func Errors

func Errors(err error) []*Error

Errors returns every *Error in err's tree, in traversal order.

errors.AsType finds the first one. Because a conversion reports all of its failures at once, this is the way to list every bad value:

for _, ce := range gofret.Errors(err) {
    log.Printf("%s: %v", ce.Path, ce.Err)
}
Example

A conversion reports every failure at once, so the joined error usually holds more than one. Errors lists them all.

package main

import (
	"fmt"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Port    int `cfg:"port"`
		Retries int `cfg:"retries"`
	}

	_, err := gofret.To[Config](map[string]any{
		"port":    "nope",
		"retries": "many",
	})

	for _, ce := range gofret.Errors(err) {
		fmt.Printf("%s: %s -> %s\n", ce.Path, ce.From, ce.To)
	}
}
Output:
port: string -> int
retries: string -> int

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Hook

type Hook func(HookCtx) (any, error)

Hook replaces a value before the built-in conversion runs.

Return ErrSkip to decline, which passes the value to the next hook and finally to the built-in conversion. Any other error aborts the conversion and is reported to the caller, so a hook can report a genuine failure instead of silently declining.

The returned value is converted to the destination as usual, so a hook may hand back an intermediate representation rather than the final type.

type HookCtx

type HookCtx struct {
	// From is the type of the source value. It is nil when the source is
	// an untyped nil.
	From reflect.Type
	// To is the type of the destination. When writing into a map[string]any
	// or an any field this is the empty interface, not the concrete type of
	// the source.
	To reflect.Type
	// Value is the source value.
	Value reflect.Value
	// Tag is the parsed struct tag of the field being converted. It is the
	// zero Tag when the value is not a struct field.
	Tag Tag
	// contains filtered or unexported fields
}

HookCtx describes the value a hook is being offered.

The cheap facts are fields; anything that costs an allocation is a method, so a hook that only inspects types and declines pays nothing.

func (HookCtx) Data

func (c HookCtx) Data() any

Data returns the source value as an any, so a simple hook needs no reflection. It boxes the value, so prefer Value when you are only going to inspect it.

func (HookCtx) Path

func (c HookCtx) Path() string

Path returns the dotted location of the value, for example "database.hosts[2].port". It is empty at the root.

The path is built on demand, so asking for it only in an error branch costs nothing on the happy path.

type KeyFunc

type KeyFunc func(string) string

KeyFunc derives a map key from a Go field name. It is only consulted for fields whose struct tag does not spell out a name.

See WithKeyFunc.

type KeyNormalizer

type KeyNormalizer func(string) string

KeyNormalizer folds a key into a canonical form used for matching.

Both the destination field names and the incoming keys are passed through the normalizer; two keys match when their normalized forms are equal. That makes matching precomputable, so lookups stay O(1) no matter how expensive the normalizer is.

See WithKeyNormalizer.

type Metadata

type Metadata struct {
	// Keys holds the dotted path of every destination field that was
	// written, for example "database.host".
	Keys []string
	// Unused holds the dotted path of every input key that matched no
	// destination field. It is the usual way to warn about typos in
	// configuration files.
	Unused []string
	// Unset holds the dotted path of every destination field that no input
	// key supplied.
	Unset []string
}

Metadata records what a conversion did. Pass one to Codec.ToIntoMeta to collect it; nothing is tracked otherwise, so the default path stays cheap.

func (*Metadata) Reset

func (m *Metadata) Reset()

Reset clears the metadata while keeping the allocated slices, so a single Metadata can be reused across conversions.

type Opt

type Opt uint16

Opt is a bitmask of struct tag options.

const (
	// OptSkip is `-`: the field is ignored in both directions.
	OptSkip Opt = 1 << iota
	// OptOmitEmpty drops the field when its value is the zero value.
	// Applies when the destination is a map.
	OptOmitEmpty
	// OptInline merges the field's keys into the parent instead of nesting
	// them under the field name. Applies in both directions.
	OptInline
	// OptRemain collects keys that match no field. Applies in both
	// directions, which keeps round-trips lossless.
	OptRemain
	// OptString converts the value to its string form.
	OptString
	// OptDeref writes the pointed-to value instead of the pointer. A nil
	// pointer yields the zero value. Applies when the destination is a map.
	OptDeref
	// OptKeep passes the value through untouched instead of converting it
	// recursively. Applies when the destination is a map.
	OptKeep
)

func (Opt) String

func (o Opt) String() string

String renders the option names, comma separated, in declaration order.

type Option

type Option func(*config)

Option configures a Codec. Options are applied in order by New.

func Alias added in v0.2.0

func Alias[Named, Base any]() Option

Alias declares that the named type Named is converted exactly like Base, so that a conversion registered for Base also serves Named:

type Timeout time.Duration

c := gofret.New(
    gofret.Between(time.ParseDuration),
    gofret.Alias[Timeout, time.Duration](),
)

It is deliberately explicit rather than inferred from the underlying type, because sharing an underlying type does not mean sharing a format: `type Celsius int64` has the same underlying type as time.Duration and must not be parsed as a duration.

Named and Base must be convertible to one another, which two types sharing an underlying type always are.

Example

Alias lends a named type the conversions of the type it is defined from. It is explicit because sharing an underlying type does not mean sharing a format.

package main

import (
	"fmt"
	"time"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Timeout time.Duration

	type Config struct {
		Read Timeout `cfg:"read"`
	}

	c := gofret.New(gofret.Alias[Timeout, time.Duration]())

	cfg, err := c.To[Config](map[string]any{"read": "1h30m"})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(time.Duration(cfg.Read))
}
Output:
1h30m0s

func Between added in v0.2.0

func Between[In, Out any](fn func(In) (Out, error)) Option

Between registers a conversion from In to Out, written as an ordinary Go function.

This is the form to reach for first. Unlike a Hook it is looked up by type rather than offered every value, so registering more of them does not make every conversion slower, and the function itself never has to decline: it is only ever called with an In.

Because the function is an ordinary one, functions from other packages need no wrapper and the registration needs no type arguments:

c := gofret.New(
    gofret.Between(time.ParseDuration),  // string -> time.Duration
    gofret.Between(netip.ParseAddr),     // string -> netip.Addr
)

The pair being registered is whatever the function's argument and result say, so this reads a value in and writes one out equally well:

gofret.Between(netip.ParseAddr)                     // string -> netip.Addr
gofret.Between(func(s Secret) (string, error) {…})  // Secret -> string

Registering the same pair twice keeps the last registration, so a conversion gofret provides by default can be replaced by registering it again.

The match is on the exact types. A named type such as `type Timeout time.Duration` is a different type and does not match; see Alias.

Example

Between teaches a codec a type it does not already know. The rule is an ordinary function, so it needs no wrapper and no type arguments.

package main

import (
	"fmt"
	"net/netip"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Addr netip.Addr `cfg:"addr"`
	}

	c := gofret.New(gofret.Between(netip.ParseAddr))

	cfg, err := c.To[Config](map[string]any{"addr": "10.0.0.1"})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(cfg.Addr)
}
Output:
10.0.0.1

func BetweenOK added in v0.2.0

func BetweenOK[In, Out any](fn func(In) Out) Option

BetweenOK is Between for a function that cannot fail:

gofret.BetweenOK(time.Duration.String)             // time.Duration -> string
gofret.BetweenOK(func(s string) Tags {…})          // string -> Tags

Choosing between the two is not a judgement call: pass the function you have and the compiler names the one that takes it.

It has no more of a direction than Between does. A String method is the commonest function that cannot fail, so this is often the way out of a type while Between is the way in, but that follows from parsing being the part that fails rather than from anything here.

Example

Registering the way out settles what a value becomes inside a map[string]any, where the destination is an empty interface and names no type of its own, so the map can be handed to an encoder and read back.

package main

import (
	"encoding/json"
	"fmt"
	"net/netip"
	"time"

	"github.com/rakunlabs/gofret"
)

func main() {
	type Config struct {
		Addr    netip.Addr    `cfg:"addr"`
		Timeout time.Duration `cfg:"timeout"`
	}

	c := gofret.New(
		gofret.BetweenOK(netip.Addr.String),
		gofret.Between(netip.ParseAddr),
	)

	m, err := c.To[map[string]any](Config{
		Addr:    netip.MustParseAddr("10.0.0.1"),
		Timeout: 90 * time.Minute,
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	raw, err := json.Marshal(m)
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Println(string(raw))
}
Output:
{"addr":"10.0.0.1","timeout":"1h30m0s"}

func TimeLayout added in v0.2.0

func TimeLayout(layouts ...string) Option

TimeLayout registers both directions of a time.Time written as text, trying each layout in turn when reading and using the first when writing:

gofret.New(gofret.TimeLayout("2006-01-02"))

A time.Time already understands RFC 3339 on its own through encoding.TextUnmarshaler, so reach for this only when the input uses some other layout. With no layouts it is RFC 3339, which is useful for pinning the way times are written rather than for reading them.

func WithDerefPointers

func WithDerefPointers() Option

WithDerefPointers treats every pointer field as if it carried the `deref` tag option when writing to a map.

func WithErrorUnused

func WithErrorUnused() Option

WithErrorUnused reports an error when the input holds keys that match no destination field and no `remain` field is present.

func WithFailFast

func WithFailFast() Option

WithFailFast stops at the first error. By default every error is collected and returned joined together.

func WithHooks

func WithHooks(hooks ...Hook) Option

WithHooks appends conversion hooks. They run in order before the built-in conversion; the first one that does not return ErrSkip wins.

A hook is offered every value, so it can match on things a type pair cannot express, such as "any type implementing this interface". When the rule is simply "this type becomes that type", prefer Between, which is looked up rather than offered and so does not grow the cost of every conversion.

func WithInlineEmbedded

func WithInlineEmbedded() Option

WithInlineEmbedded treats every embedded struct as if it carried the `inline` tag option.

func WithKeyFunc

func WithKeyFunc(fn KeyFunc) Option

WithKeyFunc sets how a map key is derived from a field name that carries no name in its struct tag. See CamelCase, SnakeCase, KebabCase and PascalCase.

func WithKeyNormalizer

func WithKeyNormalizer(fn KeyNormalizer) Option

WithKeyNormalizer replaces the fallback key matcher. Keys match when their normalized forms are equal. Passing nil disables fallback matching, making lookups exact.

The default is LooseKey. Pass FoldKey for case-insensitive matching that still respects separators.

func WithLooseKeys

func WithLooseKeys() Option

WithLooseKeys matches keys ignoring case, '-', '_' and ' ', so "MaxRetry", "max_retry" and "max-retry" all refer to the same field.

It is shorthand for WithKeyNormalizer(LooseKey) and is the default; pass it explicitly only to undo an earlier WithStrictKeys or WithKeyNormalizer.

func WithMaxErrors

func WithMaxErrors(n int) Option

WithMaxErrors caps how many errors are collected before conversion stops. A value of zero or less means no cap.

func WithOmitNil

func WithOmitNil() Option

WithOmitNil drops nil pointer, map, slice and interface fields when writing to a map.

func WithShallow

func WithShallow() Option

WithShallow treats every field as if it carried the `keep` tag option when writing to a map, so nested structs are passed through as-is instead of being converted recursively.

func WithStrictKeys

func WithStrictKeys() Option

WithStrictKeys matches keys exactly, byte for byte. It is shorthand for WithKeyNormalizer(nil) and turns off the default loose matching.

func WithStrictTags added in v0.2.1

func WithStrictTags() Option

WithStrictTags reports an error when a struct tag contains an unknown option. By default unknown options are ignored so tags can be shared with packages that have their own option vocabulary.

func WithStrictTypes

func WithStrictTypes() Option

WithStrictTypes turns off the lenient conversions described by WithWeakTypes, so only conversions that cannot lose information are performed: "42" no longer reaches an int field, 3.7 no longer truncates into one, and a negative value no longer wraps into an unsigned one.

func WithTag

func WithTag(tag string) Option

WithTag sets the struct tag to read. The default is DefaultTag.

func WithTagFallback

func WithTagFallback(tags ...string) Option

WithTagFallback adds tags consulted, in order, when the primary tag is absent on a field. It is handy for reusing existing `json` tags.

func WithTaggedOnly

func WithTaggedOnly() Option

WithTaggedOnly ignores fields that carry no struct tag, as if each of them were tagged "-".

func WithWeakTypes

func WithWeakTypes() Option

WithWeakTypes enables lenient scalar conversions:

  • bool to and from string ("1"/"0", "true"/"false")
  • number to and from string
  • bool to and from number (true == 1)
  • []byte and []rune to and from string
  • a single value lifted into a one-element slice
  • an empty map to an empty slice, and the reverse

It is the default; pass it explicitly only to undo an earlier WithStrictTypes.

func WithZeroFields

func WithZeroFields() Option

WithZeroFields zeroes a destination before writing to it. Without it, maps and slices are merged into rather than replaced.

type Tag

type Tag struct {
	// Name is the key name. Empty means "derive from the field name".
	Name string
	// Opts is the bitmask of the recognised options.
	Opts Opt
}

Tag holds the parsed struct tag of a single field.

A tag looks like `cfg:"name,opt1,opt2"`. The first comma-separated element is the key name, the rest are options.

func (Tag) Has

func (t Tag) Has(opt Opt) bool

Has reports whether opt is set. Multiple bits may be given, in which case it reports whether all of them are set.

func (Tag) HasAny

func (t Tag) HasAny(opt Opt) bool

HasAny reports whether at least one of the given bits is set.

type ValueDecoder

type ValueDecoder interface {
	DecodeValue(any) error
}

ValueDecoder lets a type choose how it is read in. It is consulted before the hooks and before the built-in conversion.

It is called on an addressable value, so implement it on the pointer receiver.

type ValueEncoder

type ValueEncoder interface {
	EncodeValue() (any, error)
}

ValueEncoder lets a type choose how it is written out. It is consulted before the hooks and before the built-in conversion.

The returned value is converted to the destination as usual.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL