Maps of functions

Avatar of the author Willem Schots
21 Mar, 2024
~7 min.
RSS

Sometimes a declarative approach makes your code a lot clearer.

Take environment variable handling for example. Some environment variables might have widly different validation rules, while others share the same rules.

Organizing code like this using just if statements can be challenging. Ideally you’d just specify the enviroment variable’s name and the relevant rules

Now, there are great packages like spf13/viper and kelseyhightower/envconfig that use reflection and struct tags to provide a more declarative solution.

But is it really necessary to introduce a dependency for this?

Let’s see how far we can come with just a map of functions.

I'm building an open source example web application and this environment variable situation is exactly what inspired this article.

However, I've also used this solution in many other data-mapping situations, I'm sure you will as well :)

Map of functions

When I write “map of functions” I mean data types like this:

var m = map[string]func(){
  // ...
}

Here, string is the map’s key type and func() is the value type.

For our environment variable example, we might want to do the following:

  • Take a key-value pair (both strings).
  • Process each pair according to some rules.
  • Put the results in a Config struct.

In code, a solution that uses a map of functions could look like this.

config.go
package main

import (
	"errors"
	"fmt"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Timeout time.Duration
	Number  int
}

var envMap = map[string]func(v string, c *Config) error {
	"TIMEOUT": func(v string, c *Config) error {
		dur, err := time.ParseDuration(v)
		if err != nil {
			return err
		}
		c.Timeout = dur
		return nil
	},
	"NUMBER": func(v string, c *Config) error {
		nr, err := strconv.Atoi(v)
		if err != nil {
			return err
		}
		c.Number = nr
		return nil
	},
	// imagine more environment variables here.
}

func configFromEnv() (Config, error) {
	// Config with default values.
	c := Config{
		Timeout: 5 * time.Second,
		Number:  101,
	}

	var errSum error
	for key, mf := range envMap {
		if val, ok := os.LookupEnv(key); ok {
			if err := mf(val, &c); err != nil {
				errSum = errors.Join(errSum, fmt.Errorf("invalid env variable %s: %w", key, err))
			}
		}
	}

	return c, errSum
}
main.go
package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	// This is usually done outside of your app
	os.Setenv("TIMEOUT", "10s")
	os.Setenv("NUMBER", "-1")

	c, err := configFromEnv()
	if err != nil {
		log.Fatalf("config error: %v", err)
	}

	fmt.Printf("%+v\n", c)
}

The key function in this example is configFromEnv:

  1. We first set up a Config with some sensible default values.
  2. We then iterate over the envMap, calling the relevant function. The functions are provided with a pointer to the config so that they can modify it as required.
  3. Finally, we collect any errors and return the Config.

If we ignore reflection and struct tags, other approaches would involve a load of if statements or a loop and a switch statement.

The map-based solution offers a benefit over these approaches: We can work with the keys as values.

Iterating over keys

For example, creating a list of environment variables supported by our app is a matter of collecting the map keys:

main.go
package main

import (
	"fmt"
)

func main() {
	// Output all environment variables supported by our app.
	for key := range envMap {
		fmt.Println(key)
	}
}
config.go
package main

import (
	"errors"
	"fmt"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Timeout time.Duration
	Number  int
}

var envMap = map[string]func(v string, c *Config) error {
	"TIMEOUT": func(v string, c *Config) error {
		dur, err := time.ParseDuration(v)
		if err != nil {
			return err
		}
		c.Timeout = dur
		return nil
	},
	"NUMBER": func(v string, c *Config) error {
		nr, err := strconv.Atoi(v)
		if err != nil {
			return err
		}
		c.Number = nr
		return nil
	},
	// imagine more environment variables here.
}

func configFromEnv() (Config, error) {
	// Config with default values.
	c := Config{
		Timeout: 5 * time.Second,
		Number:  101,
	}

	var errSum error
	for key, mf := range envMap {
		if val, ok := os.LookupEnv(key); ok {
			if err := mf(val, &c); err != nil {
				errSum = errors.Join(errSum, fmt.Errorf("invalid env variable %s: %w", key, err))
			}
		}
	}

	return c, errSum
}

Check for key existence

It’s also possible to check which enironment variables are available but ignored by our app:

main.go
package main

import (
	"fmt"
	"os"
)

func main() {
	// Output all environment variables ignored by our app.
	for _, key := range os.Environ() {
		if _, ok := envMap[key]; !ok {
			fmt.Println(key)
		}
	}
}
config.go
package main

import (
	"errors"
	"fmt"
	"os"
	"strconv"
	"time"
)

type Config struct {
	Timeout time.Duration
	Number  int
}

var envMap = map[string]func(v string, c *Config) error {
	"TIMEOUT": func(v string, c *Config) error {
		dur, err := time.ParseDuration(v)
		if err != nil {
			return err
		}
		c.Timeout = dur
		return nil
	},
	"NUMBER": func(v string, c *Config) error {
		nr, err := strconv.Atoi(v)
		if err != nil {
			return err
		}
		c.Number = nr
		return nil
	},
	// imagine more environment variables here.
}

func configFromEnv() (Config, error) {
	// Config with default values.
	c := Config{
		Timeout: 5 * time.Second,
		Number:  101,
	}

	var errSum error
	for key, mf := range envMap {
		if val, ok := os.LookupEnv(key); ok {
			if err := mf(val, &c); err != nil {
				errSum = errors.Join(errSum, fmt.Errorf("invalid env variable %s: %w", key, err))
			}
		}
	}

	return c, errSum
}

Important note on concurrency

One of the things to keep in mind is that Go maps are not safe for concurrent reads and writes.

When I use maps of functions I usually only read from them. This can be done concurrently without data races.

If for whatever reason, you do need to read and write concurrently, you’ll need to coordinate these operations. In most cases a mutex should be enough.

Re-using mapping functions

If you have several similar types of values, it might be worth creating helper functions.

For example, the example below uses a helper function that ensures that two time.Duration values are in specific ranges before setting the appropriate struct fields.

config.go
package main

import (
	"errors"
	"fmt"
	"os"
	"time"
)

type Config struct {
	IdleTimeout  time.Duration
	WriteTimeout time.Duration
}

var envMap = map[string]func(v string, c *Config) error{
	"IDLE_TIMEOUT": func(v string, c *Config) error {
		return duration(v, &c.IdleTimeout, 0, 60*time.Second)
	},
	"WRITE_TIMEOUT": func(v string, c *Config) error {
		return duration(v, &c.IdleTimeout, 0, 60*time.Second)
	},
}

func configFromEnv() (Config, error) {
	// Config with default values.
	c := Config{
		IdleTimeout:  10 * time.Second,
		WriteTimeout: 5 * time.Second,
	}

	var errSum error
	for key, mf := range envMap {
		if val, ok := os.LookupEnv(key); ok {
			if err := mf(val, &c); err != nil {
				errSum = errors.Join(errSum, fmt.Errorf("invalid env variable %s: %w", key, err))
			}
		}
	}

	return c, errSum
}

// duration attempts to parse v into tgt and checks if the result is in
// the provided range (inclusive).
func duration(v string, tgt *time.Duration, min, max time.Duration) error {
	dur, err := time.ParseDuration(v)
	if err != nil {
		return err
	}

	if dur < min || dur > max {
		return fmt.Errorf("duration %s not in range [%s, %s] (inclusive)", dur, min, max)
	}

	*tgt = dur

	return nil
}

main.go
package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	// This is usually done outside of your app
	os.Setenv("IDLE_TIMEOUT", "12s")
	os.Setenv("WRITE_TIMEOUT", "2456ms")

	c, err := configFromEnv()
	if err != nil {
		log.Fatalf("config error: %v", err)
	}

	fmt.Printf("%+v\n", c)
}

Conclusion

If you find yourself tempted to import a dependency that provides struct-tags, consider how much use it will see. If it’s only one-off, a map of functions can be a suitable alternative.

In situation where you’re not dealing with structs, maps of functions can be a decent way to organize code in a more declarative way.

But as always, use your judgement, maps of functions can be quite verbose and there is a bit of indirection that can be confusing.

Don’t hesitate to contact me if you have any questions or comments.

🎓

Subscribe to my Newsletter and Keep Learning.

Gain access to more content and get notified of the latest articles:

I send emails every 1-2 weeks and will keep your data safe. You can unsubscribe at any time.

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Twitter/X, LinkedIn or Mastodon.

Thanks for reading!