Master dyno: Simplify Go JSON/YAML Handling with Dynamic Maps

This article explains how the Go dyno package lets you effortlessly read, modify, and serialize deeply‑nested JSON/YAML structures by providing Get, Set, Delete, Append and conversion utilities that work with map[string]interface{}, map[interface{}]interface{} and []interface{} without using reflection.

Go Programming World
Go Programming World
Go Programming World
Master dyno: Simplify Go JSON/YAML Handling with Dynamic Maps

Introduction to dyno package

The dyno package is designed to simplify operations on dynamic objects. When serializing or deserializing JSON or YAML in Go, developers usually represent objects with map[string]interface{} or map[interface{}]interface{} and arrays with []interface{}. dyno supports CRUD operations on these mixed‑type structures at any nesting depth.

dyno package overview

dyno implements a set of functions that can navigate and manipulate arbitrary nested maps and slices. The core functions include Get, Set, Delete, Append, and conversion helpers such as ConvertMapI2MapS. All functions work without reflection, offering good performance.

Get function

The Get function retrieves a value from a dynamic structure (e.g., map[string]interface{} or []interface{}) by following a path of keys and/or indexes. If the path is empty, the original value is returned. The implementation iterates over the path and, depending on the current node type, extracts the next element:

If the node is map[string]interface{}, the path element must be a string key.

If the node is map[interface{}]interface{}, the path element can be any type and is used directly as the key.

If the node is []interface{}, the path element must be an int index.

Any other type results in an error.

After processing all elements, the final value is returned.

func Get(v interface{}, path ...interface{}) (interface{}, error) {
    for i, el := range path {
        switch node := v.(type) {
        case map[string]interface{}:
            key, ok := el.(string)
            if !ok {
                return nil, fmt.Errorf("expected string path element, got: %T (path element idx: %d)", el, i)
            }
            v, ok = node[key]
            if !ok {
                return nil, fmt.Errorf("missing key: %s (path element idx: %d)", key, i)
            }
        case map[interface{}]interface{}:
            var ok bool
            v, ok = node[el]
            if !ok {
                return nil, fmt.Errorf("missing key: %v (path element idx: %d)", el, i)
            }
        case []interface{}:
            idx, ok := el.(int)
            if !ok {
                return nil, fmt.Errorf("expected int path element, got: %T (path element idx: %d)", el, i)
            }
            if idx < 0 || idx >= len(node) {
                return nil, fmt.Errorf("index out of range: %d (path element idx: %d)", idx, i)
            }
            v = node[idx]
        default:
            return nil, fmt.Errorf("expected map or slice node, got: %T (path element idx: %d)", node, i)
        }
    }
    return v, nil
}

Typed getters (GetInt, GetInteger, etc.)

Typed wrappers such as GetInt and GetInteger call Get to obtain a value and then perform a type assertion or conversion. For example, GetInt returns an int and reports an error if the underlying value is not an int. GetInteger accepts many numeric types and strings, converting them to int64.

// GetInt returns an int value denoted by the path.
func GetInt(v interface{}, path ...interface{}) (int, error) {
    v, err := Get(v, path...)
    if err != nil {
        return 0, err
    }
    i, ok := v.(int)
    if !ok {
        return 0, fmt.Errorf("expected int value, got: %T", v)
    }
    return i, nil
}
// GetInteger returns an int64 value denoted by the path.
func GetInteger(v interface{}, path ...interface{}) (int64, error) {
    v, err := Get(v, path...)
    if err != nil {
        return 0, err
    }
    switch i := v.(type) {
    case int64:
        return i, nil
    case int:
        return int64(i), nil
    case int32:
        return int64(i), nil
    case int16:
        return int64(i), nil
    case int8:
        return int64(i), nil
    case uint:
        return int64(i), nil
    case uint64:
        return int64(i), nil
    case uint32:
        return int64(i), nil
    case uint16:
        return int64(i), nil
    case uint8:
        return int64(i), nil
    case float64:
        return int64(i), nil
    case float32:
        return int64(i), nil
    case string:
        var n int64
        _, err := fmt.Sscan(i, &n)
        return n, err
    case interface{ Int64() (int64, error) }:
        return i.Int64()
    default:
        return 0, fmt.Errorf("expected some form of integer number, got: %T", v)
    }
}

SGet – string‑only path getter

SGet

works on map[string]interface{} structures and accepts only string keys in the path. It returns the value at the specified location or an error if a key is missing or a non‑map node is encountered.

func SGet(m map[string]interface{}, path ...string) (interface{}, error) {
    if len(path) == 0 {
        return m, nil
    }
    lastIdx := len(path) - 1
    var value interface{}
    var ok bool
    for i, key := range path {
        if value, ok = m[key]; !ok {
            return nil, fmt.Errorf("missing key: %s (path element idx: %d)", key, i)
        }
        if i == lastIdx {
            break
        }
        m2, ok := value.(map[string]interface{})
        if !ok {
            return nil, fmt.Errorf("expected map with string keys node, got: %T (path element idx: %d)", value, i)
        }
        m = m2
    }
    return value, nil
}

Set – modify a value

Set

updates a value at a given path inside a dynamic structure. It first retrieves the parent node (using Get on the path without the last element) and then assigns the new value based on whether the parent is a map or slice.

func Set(v interface{}, value interface{}, path ...interface{}) error {
    if len(path) == 0 {
        return fmt.Errorf("path cannot be empty")
    }
    i := len(path) - 1
    if len(path) > 1 {
        var err error
        v, err = Get(v, path[:i]...)
        if err != nil {
            return err
        }
    }
    el := path[i]
    switch node := v.(type) {
    case map[string]interface{}:
        key, ok := el.(string)
        if !ok {
            return fmt.Errorf("expected string path element, got: %T (path element idx: %d)", el, i)
        }
        node[key] = value
    case map[interface{}]interface{}:
        node[el] = value
    case []interface{}:
        idx, ok := el.(int)
        if !ok {
            return fmt.Errorf("expected int path element, got: %T (path element idx: %d)", el, i)
        }
        if idx < 0 || idx >= len(node) {
            return fmt.Errorf("index out of range: %d (path element idx: %d)", idx, i)
        }
        node[idx] = value
    default:
        return fmt.Errorf("expected map or slice node, got: %T (path element idx: %d)", node, i)
    }
    return nil
}

Append – add an element to a slice

Append

locates a slice via a path, appends a new element, and writes the updated slice back using Set. It validates that the target node is indeed a slice.

func Append(v interface{}, value interface{}, path ...interface{}) error {
    if len(path) == 0 {
        return fmt.Errorf("path cannot be empty")
    }
    node, err := Get(v, path...)
    if err != nil {
        return err
    }
    s, ok := node.([]interface{})
    if !ok {
        return fmt.Errorf("expected slice node, got: %T (path element idx: %d)", node, len(path)-1)
    }
    return Set(v, append(s, value), path...)
}

Delete – remove a key or element

Delete

removes a key from a map or an element from a slice. For slices it shifts elements left, clears the last slot to avoid memory leaks, and writes the shortened slice back.

func Delete(v interface{}, key interface{}, path ...interface{}) error {
    if len(path) == 0 {
        if _, ok := v.([]interface{}); ok {
            return fmt.Errorf("path cannot be empty if v is a slice")
        }
    }
    node, err := Get(v, path...)
    if err != nil {
        return err
    }
    switch node2 := node.(type) {
    case map[string]interface{}:
        skey, ok := key.(string)
        if !ok {
            return fmt.Errorf("expected string key, got: %T", key)
        }
        delete(node2, skey)
    case map[interface{}]interface{}:
        delete(node2, key)
    case []interface{}:
        idx, ok := key.(int)
        if !ok {
            return fmt.Errorf("expected int key, got: %T", key)
        }
        if idx < 0 || idx >= len(node2) {
            return fmt.Errorf("index out of range: %d", idx)
        }
        copy(node2[idx:], node2[idx+1:])
        node2[len(node2)-1] = nil
        return Set(v, node2[:len(node2)-1], path...)
    default:
        return fmt.Errorf("expected map or slice node, got: %T (path element idx: %d)", node, len(path)-1)
    }
    return nil
}

ConvertMapI2MapS – map key conversion

The ConvertMapI2MapS function recursively walks a dynamic object and converts any map[interface{}]interface{} into map[string]interface{}. Keys that are not strings are turned into strings using fmt.Sprint. This is useful before JSON marshaling, which does not accept non‑string map keys.

func ConvertMapI2MapS(v interface{}) interface{} {
    switch x := v.(type) {
    case map[interface{}]interface{}:
        m := map[string]interface{}{}
        for k, v2 := range x {
            switch k2 := k.(type) {
            case string:
                m[k2] = ConvertMapI2MapS(v2)
            default:
                m[fmt.Sprint(k)] = ConvertMapI2MapS(v2)
            }
        }
        v = m
    case []interface{}:
        for i, v2 := range x {
            x[i] = ConvertMapI2MapS(v2)
        }
    case map[string]interface{}:
        for k, v2 := range x {
            x[k] = ConvertMapI2MapS(v2)
        }
    }
    return v
}

Typical usage scenario

dyno is ideal for processing deeply nested YAML/JSON data without defining static structs. You can read a value with dyno.Get, modify it using dyno.Set or dyno.Append, and finally marshal the structure to JSON after converting map keys with dyno.ConvertMapI2MapS. Because dyno avoids reflection, it offers good performance.

Conclusion

The article demonstrated how the dyno package enables convenient handling of dynamic data in Go, covering getters, setters, deletion, appending, and map‑key conversion. Its reflection‑free implementation makes it a performant choice for JSON/YAML manipulation.

Further reading

dyno package documentation: https://pkg.go.dev/github.com/icza/dyno

dyno GitHub repository: https://github.com/icza/dyno

Go YAML‑to‑JSON pitfalls (original Chinese article)

Example code repository: https://github.com/jianghushinian/blog-go-example/tree/main/yaml/dyno

Permanent article URL: https://jianghushinian.cn/2025/08/11/go-dyno/

GoserializationJSONCode ExampleYAMLdynodynamic-maps
Go Programming World
Written by

Go Programming World

Mobile version of tech blog https://jianghushinian.cn/, covering Golang, Docker, Kubernetes and beyond.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.