Go v2 vs v3 SDKs: Nullable and Optional Fields

Go lacks built-in optional types, so SDKs must represent fields that can be omitted, null, or both in some other way (or forgo the ability to differentiate).

v2 uses a wrapper param.Opt[T] in request types to handle this disambiguation, with separate response types without wrappers for idiomatic reads. v3 uses unified types, T/*T for fields, and handles disambiguation beyond that separately.

In this example, to show all cases, Name is required+non-nullable, Age is optional+non-nullable, Email is required+nullable, and Bio is optional+nullable.

type UserParam struct {
    Name  string            `json:"name"`
    Age   param.Opt[int]    `json:"age,omitzero"`
    Email param.Opt[string] `json:"email"`
    Bio   param.Opt[string] `json:"bio,omitzero"`
    paramObj
}

type User struct {
    Name  string `json:"name"`
    Age   int    `json:"age"`
    Email string `json:"email"`
    Bio   string `json:"bio"`
    JSON struct {
        Name  respjson.Field
        Age   respjson.Field
        Email respjson.Field
        Bio   respjson.Field
        raw   string
    } `json:"-"`
}
type User struct {
    Name     string               `json:"name"`
    Age      *int                 `json:"age,omitzero"`
    Email    *string              `json:"email"`
    Bio      *string              `json:"bio,omitzero"`
    APIData apidata.APIData `json:"-"`
}

When setting values on requests, v2 suggests using helpers to create Opt boxes. In v3 we can use the builtin new to make pointers.

user := api.UserParam{
    Name: "Alice",
    Age:  api.Int(30),
    ...
}
client.UpdateUser(user)
user := api.User{
    Name: "Alice",
    Age:  new(30),
    ...
}
client.UpdateUser(user)

To send a required but nullable field as JSON null, v2 requires param.Null[T](). In v3, a Go nil *T without omitzero serializes to JSON null, so you could either not set it or explicitly set it to nil:

user := api.UserParam{
    ...,
    Email: param.Null[string](),
}
user := api.User{
    ...,
    Email: nil,
}

For optional+nullable fields like Bio, sending explicit null is trickier in v3: nil + omitzero would omit the field, not send null. v2 handles this with param.Null as before. In v3, use SetField to express the intent:

user := api.UserParam{
    ...,
    Bio: param.Null[string](),
}
user := api.User{
  ...,
  APIData: apidata.SetField("bio", nil)
}

On the response side, Name is a plain string in both versions. For required+nullable Email, v2 uses string with JSON metadata to detect null; v3 uses *string:

fmt.Println(response.Name)

if response.JSON.Email.Valid() {
    fmt.Println(response.Email)
}
fmt.Println(response.Name)

if response.Email != nil {
    fmt.Println(*response.Email)
}

For optional non-nullable fields like Age, v2’s separate response type uses a plain int — you need the JSON metadata struct to know whether the field was present. v3 uses a pointer, so it’s a simple nil check:

if response.JSON.Age.Valid() {
    fmt.Println(response.Age)
}
if response.Age != nil {
    fmt.Println(*response.Age)
}

For optional+nullable fields like Bio, the zero value is ambiguous — omitted or explicitly null? Both need metadata to disambiguate. In v2, Valid() means “present and non-null”, so Raw() distinguishes null from omitted. v3 uses GetResponseField with map-style ok semantics to handle the three cases.

if response.JSON.Bio.Valid() {
    fmt.Println(response.Bio)
} else if response.JSON.Bio.Raw() == "null" {
    fmt.Println("null")
} else {
    fmt.Println("omitted")
}
bio, ok := response.APIData.GetResponseField("bio")
if !ok {
  fmt.Println("omitted")
} else if bio == nil {
  fmt.Println("null")
} else {
 fmt.Println(bio)
}