Skip to main content

An option type for GoLang

·875 words·5 mins
golang module option
Author
Emanuel Bennici
Table of Contents

Since I had to write some Rust for an internal project, I started to love the std::option::Option type.

This is why I created the typact library. The goal behind the module is to provide an easy to use and hard to misuse option type.

What is an option type?
#

Feel free to skip this section if you already know that an option type is.

Let’s take a look at the std::option::Option enum in Rust:

pub enum Option<T> {
    None,
    Some(T),
}

Where None represents no value and Some(T) represents some value of type T.

For example, imagine you wrote a application which loads a config file and you want to make the log_level field optional. You could write the following code in rust to represent the config structure:

pub struct Config {
    pub log_level: Option<String>,
}

This would allow the user to write the following config:

log_level: null

The user could even drop the log_level statement.

TL;DR: an option type allows you to define an optional value.

The std::option::Option type and typact.Option provide helper methods to make it easier to work with optional values.

For example the UnwrapOr method. It allows one to either get the value provided by, e.g., the user or the default value.

Let’s take the Rust Config example from above. In our example application we define the info level as the default log level:

fn do_something(cfg: Config) {
    let log_level = cfg.log_level.unwrap_or("info".to_string());
    println!("{}", log_level);
}

Now we can sure that log_level always has a value.

Please note that the rust example is simplified and you probably want to use an enum.

For sake of simplicity, a String was used.

We can do the same in GoLang:

import "go.l0nax.org/typact"

type Config struct {
    LogLevel typact.Option[string]
}

func doSomething(cfg Config) {
    logLevel := cfg.LogLevel.UnwrapOr("info")
    println(logLevel)
}

Why?
#

Optional values are common if you’re communicating with other services. For example, let’s say you have a REST endpoint that expects a body like this:

{
    "full_name": <string>,
    "email": <string>,
    "phone": <optional string>
}

In TypeScript you could define the following interface:

interface PersonRequest {
    full_name: string;
    email: string;
    phone?: string;
}

In Go you are forced to use a string pointer or assume an empty string is equal to undefined:

type PersonRequest struct {
    FullName string  `json:"full_name,omitempty"`
    Email    string  `json:"email,omitempty"`
    Phone    *string `json:"phone,omitempty"`
}

I don’t like either solution (pointer or zero value). And sometimes, you want to be able to distinguish between undefined and the zero value. I.e., the zero value is a valid value. In this case, you are forced to use a pointer.

And I really like the convenience of using helper methods like Or or AndThen. Lets have a look at the following example:

type Options struct {
    // ...

    BaseURL typact.Option[string]

    // ...
}

func doSomething(opts Options) {
    // ...

    url := opts.BaseURL.
        Or(typact.Some("https://default.domain")).
        AndThen(func(base string) typact.Option[string] {
            if strings.HasSuffix(base, "/api/pubsub/v1") {
                return typact.Some(base)
            }

            return typact.Some(base + "/api/pubsub/v1")
        }).
        Unwrap()

    // ...
}

In my opinion using the Or and AndThen methods it is much clearer to read and easier to understand what is going on.

You could argue that this is the case when defining that if BaseURL is empty, it is not provided.

If you want to maintain consistency throughout your code base and, let’s say you decided to use pointer and nil as an indicator that a value has not been provided, you would need to write the following code:

// toPtr returns a pointer to v.
//
// NOTE: We can use that this helper function has been defined
// somewhere or you are using the great https://github.com/samber/lo library.
func toPtr[T any](v T) *T {
    return &v
}

func doSomething(opts Options) {
    var url string

    if opts.BaseURL == nil {
        url = "https://default.domain/api/pubsub/v1"
    } else {
        url = *opts.BaseURL
        if !strings.HasSuffix(url, "/api/pubsub/v1") {
            url += "/api/pubsub/v1"
        }
    }
}

Please note that the non-pointer version is much shorter than both of the examples above:

func doSomething(opts Options) {
    url := opts.BaseURL
    if url == "" {
        url = "https://default.domain/api/pubsub/v1"
    } else if !strings.HasSuffix(url, "/api/pubsub/v1") {
        url += "/api/pubsub/v1"
    }
}

In my opinion, even the zero value approach is harder to read compared to the typact.Option approach.

Quick example
#

The module can be retrieved by executing

$ go get go.l0nax.org/typact

You can find the full module documentation here on pkg.go.dev.

Additionally, the typact.Option type can be used to represent a nullable column in a database table.

Please note that this library was created before database/sql.Null was introduced with go 1.22.0 (see the GitHub Issue). Nevertheless, typact.Option provides full DB compatibility with the additional helper methods.

Future ideas
#

As described in the Why? section, the option type might allow a project to prevent the one billion dollar mistake. I.e., any pointer wrapped in an option type that contains a value will never be nil.

Yes, it will never be able to throw an error at compile time like in Rust – at least not until the Go team decides to introduce an option type with such capabilities - but with a linter detecting such misuse of the option type, we could reduce the probability of introducing such a bug into the codebase.

For now, I’m unsure if this is a good idea.