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.
example web application and this environment variable situation is exactly what inspired this article.
I'm building an open sourceHowever, 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.
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
}
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
:
- We first set up a
Config
with some sensible default values. - 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. - 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:
package main
import (
"fmt"
)
func main() {
// Output all environment variables supported by our app.
for key := range envMap {
fmt.Println(key)
}
}
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:
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)
}
}
}
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.
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
}
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.
Get my free newsletter every second week
Used by 500+ developers to boost their Go skills.
"I'll share tips, interesting links and new content. You'll also get a brief guide to time for developers for free."