DEV Community

Cover image for 5 Essential Go Utility Functions: Filter, Map, and More (With Examples)
Ivan Korostenskij
Ivan Korostenskij

Posted on

5 Essential Go Utility Functions: Filter, Map, and More (With Examples)

Did you know Go only has 25 keywords (e.g., if, for, return)? Python has 38, and Java has 67. This represents the rest of Go: a minimal, approachable language that allows the quick prototyping of Python at the speed of C++. This simplicity comes at a cost: it’s common to feel like common core functionality is missing, especially if you’re coming from other languages.

Some commonly used utilities

  • Map: Take a list of items, apply something to each one
  • Filter: Take a list of items, filter them all by a condition
  • Contains: Given a list, return True/False if it has a specific element

The Go team has long argued most requests were too “trivial" - and that users should use an if statement. Only in late 2023 did they add the min() and max() functions.

However, we learn early on in programming - explicit, declarative, human-readable code is the way to go, not bunches of for and if loops.

In this article, we’ll create and understand how to make 5 utility functions that’ll transform your code into reusable, clean, declarative expressions. You’ll be able to start using these immediately (I highly encourage it) - fitting into any workflow, all under 20 lines of code.

The preview:

  1. Ternary
  2. Filter
  3. Map
  4. Unique
  5. Must

Perform a ternary operation

Run code my for this section right here: https://go.dev/play/p/k9WTQFGUD96

Coming from most other programming languages, you're probably used to inline conditionals:

const temperature = 15;
const targetTemp = 20;

const roomStatus = temperature < targetTemp ? "Heating" : "Stable";
Enter fullscreen mode Exit fullscreen mode

Or Python’s version:

temperature: int = 15
target_temp: int = 20

room_status: str = "Heating" if temperature < target_temp else "Stable"
Enter fullscreen mode Exit fullscreen mode

In Go, there's no built-in ternary operator - which feels limiting at first, but we can build one that's actually more flexible:

func Ternary[T any](condition bool, trueVal T, falseVal T) T {
    if condition {
        return trueVal
    }
    return falseVal
}
Enter fullscreen mode Exit fullscreen mode

We’ll break down the[T any] syntax in a moment.

Let's see this in action. Instead of writing:

shipName := ""
defaultName := "The Flying Dutchman"
finalName := ""

if shipName != "" {
    finalName = shipName
} else {
    finalName = defaultName
}
Enter fullscreen mode Exit fullscreen mode

We can express the same logic in a single line:

shipName := "" 
defaultName := "The Flying Dutchman"

finalName := Ternary(shipName != "", shipName, defaultName)
Enter fullscreen mode Exit fullscreen mode

The result is more readable, reusable, and declarative: the code describes what we want, not the mechanics of how to get it.

Imperative vs Declarative

Imperative code describes how to do something step by step. Declarative code defines what outcome you want, hiding the implementation details behind a clear interface. Ternary(condition, a, b) immediately communicates intent, while the six-line if/else block makes you parse the logic yourself. Design your high-level code for humans, not compilers.

A quick note on generics (T any)

func Ternary[T any](condition bool, trueVal T, falseVal T) T {
Enter fullscreen mode Exit fullscreen mode

The [T any] part is a type parameter, it tells Go that we don’t know what type trueVal and falseVal are going to be (e.g., string, int, bool ?), but we promise to fill those in when we call the function.

Without generics, we’d need separate functions for every type: TernaryString(), TernaryInt, TernaryBool...

With generics, one function handles them all, while ensuring type safety: Because type T any is the type of both trueVal and falseVal, our function will yell at us if we try to make them different:

name := Ternary(userIsLoggedIn, user.UserName, "Guest")         // T is string
count := Ternary(hasItems, itemCount, 0)                        // T is int
config := Ternary(isProd, prodConfig, devConfig)                // T is Config (object)

// Illegal (types of input variables don't match)
colorScheme: Ternary(isDarkMode, "background-black", false)
Enter fullscreen mode Exit fullscreen mode

Go infers T from the arguments you pass - it’s smart enough to understand T is a string, int, whatever… without you needing to explicitly tell it. So, our utility function now adapts to whatever we throw at it.

Filter

Arguably the most useful utility here, Filter takes a list and a function, returning a list of elements that pass your filter’s test.

Here’s how we’ll do it:

func Filter[T any](collection []T, predicate func(item T, index int) bool) []T {
    result := make([]T, 0, len(collection))
    for i, item := range collection {
        if predicate(item, i) {
            result = append(result, item)
        }
    }
    return result
}
Enter fullscreen mode Exit fullscreen mode

In practice:

activeUsers := Filter(users, func(u User, _ int) bool {
    return u.Status == "active"
})
Enter fullscreen mode Exit fullscreen mode

Three things make this implementation both efficient and reusable:

  1. Using generics keep this flexible
func Filter[T any](collection []T, predicate func(item T, index int) bool) []T
Enter fullscreen mode Exit fullscreen mode

The type parameter T again means this works with strings, integers, maps, and even custom objects - while still retaining type-safety based on our constraint:

  • a list of T must be provided
  • a type of T must be the input to our filtering function we pass (predicate)
  • a list of T must come out
  1. Pre-allocating our array makes this fast and memory efficient

Starting with a simply array and adding an item to it:

fruitList := []str{"apple", "tomato?", "kiwi"}

fruitList = append(fruitList , "banana")
Enter fullscreen mode Exit fullscreen mode

Every time we exceed the list’s current capacity (3 things), Go has to:

  1. Make an entirely new array (usually doubles in size as a guess!)
  2. Copy everything from the previous array over to the new one, ordered
  3. Return you the new list

So, we’ll tell Go “Create a list of type T , which is currently empty, but save space for up to len(collection) items - that’s the max.”


result := make([]T, 0, len(collection))
Enter fullscreen mode Exit fullscreen mode
  1. The index parameter allows us flexibility of using the index of the list elements to filter

While you can ignore the index with _, having it available lets you handle position-based filtering when needed:

// Keep only the first 5 items
first5 := Filter(items, func(item Item, i int) bool {
    return i < 5
})

// Keep every other item
evens := Filter(items, func(item Item, i int) bool {
    return i%2 == 0
})

// Combine position and value logic
validRecords := Filter(records, func(r Record, i int) bool {
    if i < 10 {
        return r.Priority == "high"
    }
    return r.Status == "active"
})
Enter fullscreen mode Exit fullscreen mode

Or just ignore it:

activeUsers := Filter(users, func(u User, _ int) bool {
    return u.Status == "active"
})
Enter fullscreen mode Exit fullscreen mode

Tip: Extract your filtering functions

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

Martin Fowler (from Refactoring: Improving the Design of Existing Code)

Inline lambdas, although convenient code golf, are hard to read and impossible to test. Instead of this:


validProducts := Filter(products, func(p Product, _ int) bool {
    return slices.Contains(allowedBrands, p.Brand) &&
           p.Status != "discontinued"
}
Enter fullscreen mode Exit fullscreen mode

Extract the logic into a named function (add early returns for extra readability!):

func isValidProduct(p Product, _ int) bool {
    if !slices.Contains(allowedBrands, p.Brand) {
        return false 
    }
    if p.Status == "discontinued" {
        return false
    }
    return true
}

validProducts := Filter(products, isValidProduct)
Enter fullscreen mode Exit fullscreen mode

Now it's testable in isolation and readable at the call site.

Map

Where Filter shortens a list, Map transforms it, element by element - often into different types.

// Map transforms elements from type T to type R.
func Map[T, R any](collection []T, transform func(item T, index int) R) []R {
    result := make([]R, len(collection))
    for i, item := range collection {
        result[i] = transform(item, i)
    }
    return result
}
Enter fullscreen mode Exit fullscreen mode

We use it like this:

// Extract: Take a list of users and extract their IDs into a list
userIDs := Map(users, func(u User, _ int) int64 { return u.ID })

// Transform: Take a list of Orders and return them along with their total cost
responses := Map(orders, func(o Order, _ int) OrderResponse {
    return OrderResponse{ID: o.ID, Total: o.CalculateTotal()}
})
Enter fullscreen mode Exit fullscreen mode

The same efficiency gains are made here as from Filter :

  1. Pre-allocating the exact result size (make([]R, len(collection))) eliminates append overhead entirely since we know the output length
  2. The dual type parameters [T, R any] allow transforming between any types
    1. Users to IDs
    2. Orders to API responses
    3. Raw data to nicely-structured DTOs

Extract your transform functions too

func toUserID(u User, _ int) int64 { 
    return u.ID 
}

userIDs := Map(users, toUserID)
Enter fullscreen mode Exit fullscreen mode
func toOrderResponse(o Order, _ int) OrderResponse {
    return OrderResponse{
        ID:    o.ID, 
        Total: o.CalculateTotal(),
    }
}

responses := Map(orders, toOrderResponse)
Enter fullscreen mode Exit fullscreen mode

Explicit is better than implicit; simple is better than complex; and declarative is better than imperative:

  • Before: responses := Map(orders, func(...) { ... something something complex logic ... })
    • What is this doing?
  • After: responses := Map(orders, toOrderResponse)
    • Ah ok, we’re transforming orders into API response objects. Intent is immediate.

No matter what language you’re working with, take this to heart:

https://peps.python.org/pep-0020/

Unique: Filtering out unique items efficiently with a Go set implementation

Ever noticed how there isn’t a built-in Setdata structure?

  • A mutable, unique array with an O(1) lookup, add, remove, and contains operations

We’ll build one with a map of struct{} values. Let’s say your key, in this map, is a string (16 bytes), you might think that to make this a map we have to store something with the value; we don't. Instead, we can store an empty struct (a struct{}{} - meaning a blank, uninitialized variable), which takes 0 bytes! So the map just tracks which keys exist, not anything else about them.

Go's compiler optimizes away empty structs entirely - they have no fields, so there's literally nothing to store in memory

It’s like going to a librarian, asking them if they have a copy of Moby Dick, getting an answer within 100 milliseconds, but getting no further information where it is. For a set - where all we need to do is check inclusion, add, and remove unique keys, this is all we need.

func Uniq[T comparable](collection []T) []T {
    seen := make(map[T]struct{}, len(collection))
    result := make([]T, 0, len(collection))

    for _, item := range collection {
        if _, exists := seen[item]; !exists {
            seen[item] = struct{}{}
            result = append(result, item)
        }
    }
    return result
}

Enter fullscreen mode Exit fullscreen mode

We use it like this:

uniqueTags := Uniq([]string{"go", "rust", "go", "python", "rust"})

// Result: ["go", "rust", "python"]
Enter fullscreen mode Exit fullscreen mode

Notice our function takes a different type of generic - T comparable instead of T any. This is a type constraint: meaning that the items we store (the keys) have to allow == and != operations, which is crucial for our set implementation.

Practically, this means that this will work with strings, numbers, booleans, and objects (structs) with comparable fields, but not lists or other maps (which don’t support == comparison in Go).

Internally, this implementation works by: for each item, checking if it exists in our map seen (pre-allocated!). If it’s found, skip it, otherwise add it to 1) our map seen and 2) our output list we’ll return result

if _, exists := seen[item]; !exists {
    seen[item] = struct{}{}
    result = append(result, item)
}
Enter fullscreen mode Exit fullscreen mode

Must: panic if something goes catastrophically wrong

This is something I use frequently to cut down on initialization boilerplate.

Most applications will have startup code that looks like this:

cfg, err := loadConfig(cfgPath)

if err != nil {
    return fmt.Errorf("failed to load config: %w", err)
}

Logger, err = setupLogger(cfg.LogLevel)
if err != nil {
    return fmt.Errorf("failed to setup logger: %w", err)
}

DB, err = sql.Open(cfg.DBDriver, cfg.DSN)
if err != nil {
    return fmt.Errorf("failed to open DB connection: %w", err)
}

// Make sure the server is responsive
response, err = DB.Ping()
if err != nil {
    DB.Close()
    return fmt.Errorf("failed to ping DB: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

This error handling is verbose and arguably pointless. If any of these fail, your app can’t run anyway immediately fail. Why bubble errors up through initialization when you could just fail immediately?

Let’s clean this up to reduce boilerplate and increase readability with this utility function:

func Must[T any](v T, err error) T {
    if err != nil {
        panic(err)
    }
    return v
}
Enter fullscreen mode Exit fullscreen mode

Now initialization becomes:

AppConfig = Must(loadConfig(cfgPath))

Logger = Must(setupLogger(AppConfig.LogLevel))

DB = Must(sql.Open(AppConfig.DBDriver, AppConfig.DSN))
Must(DB.Ping())
Enter fullscreen mode Exit fullscreen mode

Clean, readable, and explicit about intent: these operations must succeed, or we fail fast.

Warning: Use Must strictly for initialization of critical services your app cannot function without. Do not use it in regular application logic - that's where proper error handling belongs.

Learn here, but use lo - a Go library dedicated to production-ready utility functions

The point of this article was to walk through idiomatic, well-engineered implementations of common Go utility functions. Understanding how they work will make you a better Go developer - but that doesn’t mean you should reinvent the wheel every project.

https://github.com/samber/lo/

Lo, by samber, is a battle-tested Go utility library that provides a wide array of smartly-optimized, robust helper functions like the ones we made today, plus about 100 more:

  • Filter
  • Map
  • Reduce
  • ForEach
  • Flatten
  • First / FirstOrEmpty
  • Uniq / UniqBy
  • GroupBy / GroupByMap
  • Chunk / PartitionBy

Use this article to understand the patterns and customize to your use case. If you find yourself needing more, use lo in production.

Putting it together

Let's use these utilities compose in a practical example: imagine we're processing hashtags from a social media post - we need to clean them up, validate them, and deduplicate them:

func processHashtags(raw []string) []string {
    normalizedHashtag := Map(raw, normalizeHashtag)
    validHashtags := Filter(normalizedHashtag, isValidHashtag)
    return Uniq(validHashtags)
}

func normalizeHashtag(tag string, _ int) string {
    tag = strings.TrimSpace(tag)
    tag = strings.ToLower(tag)
    tag = strings.TrimPrefix(tag, "#")
    return tag
}

func isValidHashtag(tag string, _ int) bool {
    if len(tag) == 0 || len(tag) > 30 {
        return false
    }
    if slices.Contains(bannedWords, tag) {
        return false
    }
    return true
}
Enter fullscreen mode Exit fullscreen mode

Usage:

raw := []string{"  #GoLang ", "#RUST", "#go", "#ThisTagIsWayTooLongToBeUseful", "#spam"}
bannedWords := []string{"spam", "promo"}

result := processHashtags(raw)
// Result: ["golang", "rust", "go"]
Enter fullscreen mode Exit fullscreen mode

Notice how readable the processHashtags function at first glance:

  1. We normalize hashtags - which includes trimming white space, lowercasing, and removing the “#”
  2. We remove invalid hashtags - those who are either too short or too long; or contain a banned word
  3. We de-duplicate them

Boom, this is the payoff of building smart utilities and writing declarative code - our business logic reads like a description of what it does.

Wrapping up

That’s it!

You now have a toolkit of patterns that will serve you well beyond these specific functions:

  • Pre-allocation to avoid repeated memory allocations
  • Empty structs (struct{}{}) for zero-cost set implementations
  • Generics to write utilities once and use them everywhere
  • Extracted filter and transform functions to keep code testable and intent clear

More importantly, you've seen how to write Go that's both performant and readable - because the best code is code your teammates (and future you) can quickly understand and iterate on.

Code is read much more often than it is written.* We are constantly reading code for comprehension, to fix bugs, or to add new features. Making sure your code is as readable as possible will pay dividends for the entire lifecycle of the program.”*

Guido van Rossum (made Python)


If this helped you level up your Go, I'd love to hear about it! Drop a comment about a topic in Go or Python you’re interested in me covering. And if you want more practical Go content like this, give me a follow - I'm just getting started :)

Top comments (0)