Default value in Golang and zero value

Written byKalanKalan
💡

If you have any questions or feedback, pleasefill out this form

This post is translated by ChatGPT and originally written in Mandarin, so there may be some inaccuracies or mistakes.

In Go, if you don't assign a value during initialization, it will use the zero value.

However, after using it for a while, you'll notice that if we always use the zero value as a substitute, it becomes unclear whether the user didn't input a value leading to a zero value, or if the user actually inputted a zero value.

type User struct {
    ID    string
    Email string
    Name  string
}

func main() {
    user := &User{
        ID: "1",
    }
}

At this point, since both Email and Name are not provided, they default to "". So far, this seems fine, but what if we want to serialize it to JSON?

package main
import (
  "fmt"
  "encoding/json"
)
type User struct {
    ID    string `json:"id"`
    Email string `json:"email"`
    Name  string `json:"name"`
}

func main() {
    user := &User{
        ID: "1",
    }
    d, _ := json.Marshal(user)
    fmt.Println(string(d))
}

This will output:

{
    "id":"1",
    "email":"",
    "name":""
}

Often, our requirement is to represent email and name as null when there are no values instead of "". How can we modify this effectively?

1. Use Pointers

We know that the initial value of a primitive type cannot be nil, and we can't use operations like if !str {...} to check, which leads to mismatched types string and nil (thanks to habits from dynamic languages).

So if a field needs to accept a nil value, consider using pointers, for example:

package main

import (
  "fmt"
  "encoding/json"
)

type User struct {
    ID    *string `json:"id"`
    Email *string `json:"email"`
    Name  *string `json:"name"`
}

func ToStringPtr(str string) *string {
    return &str
}

func main() {
    user := &User{
        ID: ToStringPtr("1"),
		Email: ToStringPtr("kalan@gmail.com"),
    }
    d, _ := json.Marshal(user)
    fmt.Println(string(d))
}

The output will now be:

{
    "id":"1",
    "email":"kalan@gmail.com",
    "name":null
}

This looks much better, but is this really a good approach? Let's keep exploring.

Now that all the fields have become pointers, if you directly access user.Email to get the value:

package main

import (
  "fmt"
)

type User struct {
    ID    *string `json:"id"`
    Email *string `json:"email"`
    Name  *string `json:"name"`
}

func ToStringPtr(str string) *string {
    return &str
}

func main() {
    user := &User{
        ID: ToStringPtr("1"),
		Email: ToStringPtr("xxx@gmail.com"),
    }
    fmt.Println(user.Email) // 0x1040c130
  	val, _ := json.Marshal(user)
}

Directly accessing the value will return the pointer address. To get the value, you need to rewrite it as *user.Email. However, doesn't *user.Email feel a bit cumbersome? For example, if we directly try to get the value *user.Name:

func main() {
    user := &User{
        ID: ToStringPtr("1"),
    }
    fmt.Println(*user.Name)
}

This will panic. panic: runtime error: invalid memory address or nil pointer dereference occurs because the initial value is nil, indicating it is a null pointer, and dereferencing a null pointer will lead to a panic. Thus, while we can use nil to represent an empty value, this approach has significant drawbacks. We need to check whether the pointer is nil before using it.

Additionally, if this struct is intended for SQL scanning, it can be combined with sql.NullString or the package null.

sql.NullString / sql.NullInt, etc.

If the struct we define is meant for mapping values from a database, we need to implement an interface called Scanner. If a string is null, it cannot be mapped in SQL. sql.NullString implements the Scanner interface. If directly converted to JSON, it would look like this:

{
  "email": {
    "Valid": true,
    "String": "xxx@gmail.com"
  }
}

You will need to use MarshalJSON to convert it into the correct JSON format (though this format is acceptable as well...).

null

The null package helps define common data types, making it easy to use with SQL or JSON.

type User struct {
    ID    null.String `json:"id"`
    Email null.String `json:"email"`
    Name  null.String `json:"name"`
}

2. Rewrite MarshalJSON

In Go, we can define a custom type for a primitive type and add methods to it. We can override MarshalJSON to make the default output for what should be a zero value as null, like this:

type nullString string

func (str nullString) MarshalJSON() ([]byte, error) {
	raw := string(str)
	if string(str) == "" {
		return json.Marshal(nil)
	}
	val := raw
	return json.Marshal(val)
}

func main() {
	str := nullString("")
	val, _ := json.Marshal(str)
	fmt.Println(string(val))
}

3. Default Value + Tag

Go does not have syntactic sugar like some higher-level languages, such as:

const getPerson = (id = 1, name = '') => (
)
def get_person(id=1, name='', is_admin=False):
    ...

Nor does it have a convenient ternary operator to help determine default values. Therefore, implementing default values in Go can be a bit cumbersome. There have been discussions on forums about this, but they were overshadowed by responses explaining:

Why does this need additional, special support in the language? Go has ... to support a variable number of arguments, from which you could figure out which type of call it means, and supply defaults for missing arguments. Or you could pass some out-of-range or zero value like nil or -1 or even 0 to required arguments to ask for a default value. This has a long tradition in C, so why are function-specific calling conventions not enough? I think I'd rather see myfunc(nil, nil, nil, nil, nil) than myfunc(, , , ,) to say "do what you do with all default values", since while I'm developing a program it conflates whether I missed a parameter completely or meant to ask for a default value. This can cause silent errors in cases where the default value is not acceptable to the overall program. (As the code base grows we won't always be able to control the defaults for code we use in others' packages.) I also don't see why it's necessary to add a special token to represent "default value", since we can accomplish this without adding more symbols to the language.

The reason seems to be that Google does not want you to pass a bunch of func(nil, nil, nil, nil) just to make default values work, or you can directly use dynamic args… to calculate the number and type of parameters without needing default values.

But what if you really need default values? What if you don’t want the initialized values to be 0, "", etc.? In this case, you can try using Go's built-in tag functionality, like defining:

type People struct {
  Name string `default:""`
  Age int64 `default:10`
}

This way, you can achieve your goal, but implementing a fully functional tag requires careful consideration. Perhaps we'll try implementing it another day.

Conclusion

This article attempts to summarize the issues one might encounter when using default values and zero values in Go and proposes solutions to these challenges.

If you found this article helpful, please consider buying me a coffee ☕ It'll make my ordinary day shine ✨

Buy me a coffee