Generics had been one of the most requested language features in Go since the first day of of it’s open source release. After more than a decade of waiting, the Go team finally introduced Generics in March 2022 as part of Go 1.18 release. Quoting the Go team: “Generics are the biggest change we’ve made to Go since the first open source release”. In this post, I’ll demonstrate the capabilities of Generics through a practical example taken from one of the projects I worked on. We will see how Generics helped me reduce redundant code, build nice abstractions and increased readability when working with Mongo DB.

What are Generics?

So first let’s get a quick idea of what Generics are and how they work in Go. Generics are a staple part of most typed languages like Java, Typescript, C# etc. In short, they provide flexibility with type safety by allowing us to write code that can be reused with different types.

Let’s try to understand this with a simple example. Let’s say we want to write a function that takes two arguments and returns the smaller of the two. But before we can start writing the function we need to think about the types of the arguments that our function will accept. Should they be integers, floats, strings or something else? For now let’s assume that we only need to compare values of type int32. So we can write the function like this:

func MinInt32(x, y int32) int32 {
    if x < y {
        return x
    }
    return y
}

Now let’s say new requirements came in, and now we need to compare values of type string as well. So we need to write another function that does the same thing but with string type. We can do that like this:

func MinStr(x, y string) string {
    if x < y {
        return x
    }
    return y
}

We can already see so much redundant code. Imagine doing this for every type we need to compare and how much repeated code that would create. Plus our code starts to look ugly cluttered with functions like MinInt32, MinInt64 MinStr etc. Wouldn’t it be nice if we just had one Min function that worked with all types. No more repeating the same logic over and over, and our code would look neater and more organized!

This is where Generics come in. With Generics we can write a single function that can be used with any type. Here’s how we can do that:

func Min[T ~int32 | ~int64 | ~string | ~float64](x, y T) T {
    if x < y {
        return x
    }
    return y
}

Here we can see a new kind of syntax for defining a function. Let’s go over it in detail.

The [T int32 | int64 | string | float64] part is called a type parameter. It’s a placeholder for a type. The T is the name of the type parameter. The int32 | int64 | string | float64 part is a type constraint, which is a set of types that the type parameter can be replaced with. Before go 1.18 an interface defined only a set of methods but to make it easier to create type constraints, now an interface can also define a set of types.

Let’s see how we can make our function neater:

type Float interface {
	~float32 | ~float64
}

type Integer interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

type Ordered interface {
    ~string | Integer | Float
}

func Min[T Ordered](x, y T) T {
    if x < y {
        return x
    }
    return y
}

Here we defined three interfaces. The Float interface defines a set of types that are either float32 or float64, similarly Integerinterface defines the different integer types. The Ordered interface defines a set of types that are either string, Integer or Float. We want our function to be generic but still want to make sure that it does not take a parameter can not be compared using the < operator. So we use the Ordered interface as a type constraint for our function. This way we have both flexibility and robust type safety.

Also why ~int and not int. In Go, we have a concept of an underlying type. For instance, we can define a custom type like this:

type MyInt int

Here MyInt is a different type than int but its underlying type is still int. So, in a generic function or type with the constraint [T ~int], we could use int, MyInt, or any other type defined as having int as its underlying type.

So now that we have a basic understanding of Generics, let’s see how we can use them to make our code more readable and maintainable when working with Mongo DB.

MongoDB and Go

We interact with a MongoDB server in Go using the mongo-go-driver package, which is the official MongoDB driver for Go. We will not go over how to use the driver in this post, but if you are not familiar with it, you can check out the official documentation.

The only important part we need to know is that once the driver makes a successful database connection, it returns a reference to an object of type mongo.Database. Using this we can start querying the database.

Mongo’s dynamic typing vs Go’s static typing

To understand our problem a little better, we will first try to see why working with MongoDB is not so trivial in Go. Short answer Dynamic vs. Static Typing. Let’s understand how.

One of the main reasons MonogDB is popular is because it is a document database. This means it does not require us to define any schema for the data that will be stored in the database. We can store any kind of data in a mongo collection. The data is stored in a format similar to JSON, known as BSON (Binary JSON). BSON supports all the data types JSON does, but also includes additional types like Date, Binary, ObjectId etc. So to fully represent a BSON object, a notation called Extended JSON is used. Extended JSON provides a way to represent all the additional data types supported by BSON in a format that conforms to the JSON RFC. For example, a JSON with a date string like this {"created_at" : "2023-11-27T00:00:00Z"} will be represented in Extended JSON like this:

{"created_at" :{
    "$date": {
        "$numberLong": "1669516800000"
    }
}}

Here $date and $numberLong are keywords used to define Extended JSON types Date and Int64 types respectively.

For ease of understanding and visualization, let’s assume for the rest of the article that BSON data is basically JSON data.

Go is statically typed, which means we have to define the structure of our data before we use it. This is in contrast to MongoDB’s dynamic typing, where each document can have a different structure. When we bring data from MongoDB into a Go program, we have to be careful while mapping this dynamic data into predefined Go structs.

Querying Data from MongoDB

If we were querying MongoDB directly using the mongo shell, we would do something like this:

db.collection('users').findOne({username: "john"})

Here the search query is just a JSON object. MongoDB does not care if the documents in the users collection doesn’t even have the username field. This query will run successfully and return no documents. If a document with field username with value john exists, it will return that document. Without actually looking at the user document we can’t know anything about what all fields it has.

Now let’s see how we would do the same thing in Go using the mongo-go-driver:

users := db.Collection("users").FindOne(nil, bson.M{"firstName": "john"})

The FindOne method is defined like this:

FindOne(ctx context.Context, filter interface{}, opts ...*options.FindOneOptions) *mongo.SingleResult

The filter parameter, which defines the find query, is of type interface{}. This means we can pass it a value of any type. But if we pass a value that is not a valid BSON object, we will get a runtime error. So we need to make sure that the value we pass is a valid BSON object.

Let’s see how we can access the returned data using the SingleResult object and map it to a Go struct:

import (
    "context"
    "fmt"
    "go.mongodb.org/mongo-driver/bson"
)


type User struct {
    Id primitive.ObjectID `bson:"_id"`
    Username string `bson:"firstName"`
}


var user User


result := db.Collection("user").FindOne(context.Background(), bson.M{"username": ""})
err := result.Decode(&user)
if err != nil {
    if err == md.ErrNoDocuments {
        // Handle the case where no user was found
    }
    // Handle other errors that occurred during the DB operation that created this SingleResult
}

// The user variable now contains the data from the user document
fmt.Println(user.Username) // Prints "john"

In the above example, we have used an object of type bson.M to define our filter query. bson.M allows us to represent an unordered BSON object. If we see the type definition of bson.M, we can see that it is just a map of type map[string]interface{}. So we can represent any JSON object as a bson.M type. Let’s try to see how a JSON (BSON) Object can be represented using bson.M:

The JSON object

{
    "name": "john",
    "address": {
        "city": "New York",
        "country": "USA"
    }
}

will be represented as a bson.M object like this:

bson.M{
    "name": "john",
    "address": bson.M{
        "city": "New York",
        "country": "USA"
    }
}

Coming back to our MongoDB operation, we can see that there are a few problems with this approach. First is that the FindOne method takes interface{} as an argument so we can easily make a mistake of passing wrongly typed value as an argument and this error will not be caught during compile time. Second is that we will have to repeat the code for decoding the returned data again and again. A cleaner and better approach would be to have a function FineOne which takes a mongo.Collection and bson.M as an argument and returns either a User Object or a well defined Error.

We can write that function like this:

import (
    "context"
    "fmt"
    "go.mongodb.org/mongo-driver/bson"
)


var NoDocumentErr = errors.New("No documents in result")
var DbErr = errors.New("Error while querying DB")


func FindOne(collection *mongo.Collection, filter bson.M, opts ...*options.FindOneOptions) (User, error) {
    var user User
    result := collection.FindOne(context.Background(), filter)
    err := result.Decode(&user)
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return user, NoDocumentErr
        }
        // Return more specific errors as needed
        return user, DbErr
    }
    return user, nil
}

Now we can use this function like this:

user, err := FindOne(db.Collection("users"), bson.M{"username": "john"})
if err != nil {
    // Handle a well defined set of errors
}
fmt.Println(user.Username) // Prints "john"

But calling this method FindOne would be wrong, because it only works with the User struct. The correct name for it should be FineOneUser. Now Imagine we had another collection post and we wanted to write a similar function for that. We would have to write another function FindOnePost. This is not very scalable or DRY. We would have to write a new function with almost the same code for every collection we have.

Better way to do this with Generics

Before the introduction of Generics we had no other choice but to write a function for every collection. But now with Generics we can write a single function FindOne that works with any struct. Let’s see how we can do that:

    import (
    "context"
    "fmt"
    "go.mongodb.org/mongo-driver/bson"
)


var NoDocumentErr = errors.New("No documents in result")
var DbErr = errors.New("Error while querying DB")


Type Model interface {
    User | Post // Add all the structs that we want to use with this function
}


func FindOne[T Model](collection *mongo.Collection, filter bson.M, opts ...*options.FindOneOptions) (T, error) {
    var document T
    result := collection.FindOne(context.Background(), filter, opts)
    err := result.Decode(&document)
    if err != nil {
        if err == mongo.ErrNoDocuments {
            return document, NoDocumentErr
        }
        // Return more specific errors as needed
        return document, DbErr
    }
    return document, nil
}

In the above method, FindOne accepts a type parameter T which has a type constraint of type Model. Model is the set of all the structs that we want to use with the generic FindOne function. So now we can use this function with any struct that we want, for example:

user, err := FindOne[User](db.Collection("user"), bson.M{"username": "john"})
if err != nil {
    // Handle a well defined set of errors
}
fmt.Println(user.Username) // Prints "john"


// Find a post where the author is the user we just found
post, err := FindOne[Post](db.Collection("post"), bson.M{"title": "My first post", "author": user.Id})
if err != nil {
    // Handle a well defined set of errors
}
fmt.Println(post.title) // Prints "My first post"

We can see how Generics helped us reduce redundant code, increase readability and build nice abstractions. We can now write a single function that works with any struct. Similarly we can build abstraction for other MongoDB operations like FindMany, UpdateOne, UpdateMany etc.