Exploring Generics in Go: Creating an Option type.

Aryeh Klein
4 min readFeb 20, 2023

--

So one of the main issues I had when learning and writing software was the lack of generics in the language.

why do I need generics?

Generics can help avoid writing boilerplate code for the same functionality for different types.

Naturally, I was excited when it was announced that generics would be introduced in go 1.18.

A good example of a use case is copying a slice.

Instead of writing a separate function for copying a slice of each type, we can write one function:

func CopySlice[T any](slice []T) []T {
newSlice := make([]T, len(slice))

for i := 0; i < len(slice); i++ {
newSlice[i] = slice[i]
}

return newSlice
}

And then when we call the function for any type of slice we can specify the type for the compiler.

newsSlice := CopySlice[string]([]string{"a", "b"})

or let the compiler infer the type and just call

newsSlice := CopySlice([]string{"a", "b"})

So how does it work?

The compiler looks for the `[]` syntax and decides in compile time the type that the caller is using.

Ok, let's implement something a little more complicated.

Option:

what is an Option?

For those of you that know Rust or Scala I am referring to the same type as in these languages.

An Option is an object that can either have a value or not have a value.

So an Option type can either be Some(value) or None and we can use multiple functionalities on it, for example when having an optional value in a json we can use Option instead of a pointer and not check if value == nil.

So how do generics help us implement this?

Instead of implementing OptionString/ OptionInt and so on…

we can implement Option[T any] for any type.

(any is a keyword for any type any=inteface{})

The first way that comes to mind is to implement an Option as such:

type Option[T any] interface {
Get() T
GetOrElse(other T)
...
}

and have the None and Some types implement this interface, but this implementation will be problematic for Unmarshaling of a json since we will need to know the concrete type to implement the marshaled interface(I can elaborate more in the comments if needed).

My implementation:

type Option[T any] struct {
value T
isFull bool
}

that is basically all we need to know about the type:

the value and if it is filled.

Now let's implement constructors that can create a Some or None:

func Some[T any](value T) Option[T] {
return Option[T]{value: value, isFull: true}
}

func None[T any]() Option[T] {
return Option[T]{isFull : false}
}

Now lets implement some basic functionality(much more can and should be added)

func (s *Option[T]) Get() T {
if s.isFull {
return s.value
} else {
panic("cannot get from None type")

}
}

func (s *Option[T]) GetOrElse(other T) T {
if s.isFull {
return s.value
} else {
return other
}
}

func (s *Option[_]) IsEmpty() bool {
return !s.isFull
}

The next step is to have it printed to stdout the way we want it so for that we will implement a custom String() function So when calling fmt package functions on this type it will use our custom String function.

func (s Option[T]) String() string {
if s.isFull {
return fmt.Sprintf("Some(%v)", s.value)
} else {
return "None"
}
}

last but not least we need to be able to write and read from json properly.

For that, we will implement our custom UnmarshalJSON and MarshalJSON functions. so the null json type will be None and the if not found will also be none(because when instantiating a new Option the isFull will be false -> more elaboration in comments if needed).

We will use an underscore when we do not need to know the type in the function implementation.

func (s Option[_]) MarshalJSON() ([]byte, error) {
if s.isFull {
return json.Marshal(s.value)
} else {
return []byte("null"), nil
}
}

func (s *Option[_]) UnmarshalJSON(data []byte) error {

if string(data) == "null" {
s.isFull = false

return nil
}

err := json.Unmarshal(data, &s.value)

if err != nil {
return err
}

s.isFull = true

return nil
}

Great, how do we use it?

Let us say we have a User and the last name and also the age is optional.

We define the user as such:

type User struct {
firstName string
lastName Option[string]
Age Option[int]
}

Let us create a new user and print it out:

user := User{
FirstName: "aryeh",
LastName: Some("lev"),
}

fmt.Printf("%v", user)

will be printed:

{aryeh Some(lev) None}

Now if we create a json from this object:

b, err := json.Marshal(user)

fmt.Println(string(b))

will be printed:

{“first_name”:”aryeh”,”last_name”:”lev”,”age”:null}

Now let us Read a json object where the age is not given:

var user1 User

err := json.Unmarshal([]byte(`{"first_name" : "aryeh", "last_name" : "klein"}`), &user1)
if err != nil {
return
}

fmt.Printf("%v", user)

will be printed:

{aryeh Some(lev) None}

Conclusions:

Generics can be a great tool and has many use cases.

We have covered alot of subjects in one article like custom json marshallers and so on to try to simulate a real use case.

The source code can be found here:

https://github.com/aryehlev/option

And is open to contributions.

--

--