Go's sql.Null[T] Will Never Get JSON Support. Here's What We Built Instead.
DEV Community

Go's sql.Null[T] Will Never Get JSON Support. Here's What We Built Instead.

Go's sql.Null[T] Will Never Get JSON Support. Here's What We Built Instead.

Go 1.22 gave us sql.Null[T] - a generic nullable type for SQL. Problem solved? No.

Try marshaling it to JSON: {"V":"Alice","Valid":true}

That's not "Alice". That's not null. That's a broken API response. And it will never be fixed.

Issue #68375 - proposal to add MarshalJSON to sql.Null[T] - was closed as infeasible. Go's policy forbids adding marshal methods to types that already have a "reasonable" default marshaling. The struct marshaling {"V":...,"Valid":...} counts as "reasonable." This is a permanent gap in Go's standard library.

The Three Bad Options (Before opt)

1. sql.Null[T] - SQL works, JSON broken

type User struct {
    Name sql.Null[string] `json:"name"`
}
// Marshals to: {"name":{"V":"Alice","Valid":true}}
// You wanted: {"name":"Alice"}

2. *string - JSON works, everything else is awkward

type User struct {
    Name *string `json:"name"`
}
  • Pointer allocation overhead on every value
  • Can't distinguish "field absent" from "field is null"
  • user.Name == nil - is this "not set" or "set to null"?

3. guregu/null - Works, but carries 10 years of legacy

guregu/null (2,070 stars) is the de facto standard. It works. But:

  • v1→v6 legacy - API evolved over 10 years, backward compatibility constraints
  • No three-state - can't distinguish "field absent" from "field null" (critical for PATCH APIs)
  • No Map / FlatMap - no functional composition
  • No OrNull constructors - every project writes the same boilerplate helpers
  • Generic Value[T] has MarshalText and Equal commented out - couldn't make them work

opt: Option<T> for Go

coregx/opt - designed from scratch for Go 1.24+. No legacy, no compromises.

import "github.com/coregx/opt"

type User struct {
    Name  opt.String `json:"name"`
    Email opt.String `json:"email,omitzero"`
    Age   opt.Int    `json:"age"`
}

user := User{
    Name: opt.StringFrom("Alice"),
    Age:  opt.IntOrNull(0), // 0 means "not set" → null
}

data, _ := json.Marshal(user)
// {"name":"Alice","age":null}
// Email omitted (omitzero) - not null, just absent

JSON works. SQL works. No pointer overhead. No boilerplate.

What Makes opt Different

1. Three-State Field[T] - The PATCH API Killer Feature

Every REST API with PATCH endpoints has this problem: how do you distinguish "the client didn't send this field" from "the client explicitly set it to null"?

type PatchUser struct {
    Name  opt.Field[string] `json:"name,omitzero"`
    Email opt.Field[string] `json:"email,omitzero"`
    Age   opt.Field[int]    `json:"age,omitzero"`
}

// Client sends: {"name":"John","email":null}
var patch PatchUser
json.Unmarshal(input, &patch)

patch.Name.IsValue()  // true → set name to "John"
patch.Email.IsNull()  // true → set email to NULL in DB
patch.Age.IsAbsent()  // true → don't touch age

Three states: absent (don't touch), null (set to NULL), value (set to value). Rust does this with Option<Option<T>>. Kotlin with nullable + optional. No Go library does this properly. Including guregu/null.

2. OrNull Constructors - No More Boilerplate

Every project using nullable types with a database writes the same helpers:

// This is in EVERY Go project with nullable DB fields
func optStr(s string) opt.String {
    return opt.NewString(s, s != "")
}

func optInt(n int64) opt.Int {
    if n == 0 {
        return opt.Int{}
    }
    return opt.IntFrom(n)
}

With opt, this is built in:

row := CompanyRow{
    City:  opt.StringOrNull(company.City()),       // "" → null
    OGRN:  opt.StringOrNull(reg.OGRN()),           // "" → null
    Count: opt.IntOrNull(company.EmployeeCount()), // 0 → null
}

One function per type. Zero boilerplate. Available for all 9 types including BoolOrNull.

3. Functional API - Map, FlatMap, Equal

Inspired by Rust's Option<T>:

// Transform if valid, propagate null
name := opt.From(" Alice ")
trimmed := opt.Map(name, strings.TrimSpace)
// opt.Value[string]{"Alice", true}

length := opt.Map(trimmed, func(s string) int { return len(s) })
// opt.Value[int]{5, true}

// Chain operations that may produce null
parsed := opt.FlatMap(input, func(s string) opt.Value[int] {
    n, err := strconv.Atoi(s)
    if err != nil {
        return opt.New(0, false)
    }
    return opt.From(n)
})

// Nil-safe comparison
opt.Equal(opt.From(42), opt.From(42))       // true
opt.Equal(opt.From(42), opt.New(0, false))  // false

guregu/null has ValueOr. That's it. No Map, no FlatMap, no OrElse.

4. Generic Value[T] That Actually Works

guregu's generic Value[T] has MarshalText and Equal commented out in the source code - they couldn't make them work generically. opt's Value[T] is fully functional:

// Works with ANY type
v := opt.From(MyCustomStruct{Name: "test"})
data, _ := json.Marshal(v)
// {"Name":"test"}

null := opt.New(MyCustomStruct{}, false)
data, _ = json.Marshal(null)
// null

5. zero/ Subpackage - Alternative Semantics

Sometimes you want zero values to be null, and null to marshal as zero (not null):

import "github.com/coregx/opt/zero"

s := zero.StringFrom("") // Invalid - empty = null
data, _ := json.Marshal(s)
// "" (not "null")

i := zero.IntFrom(0) // Invalid - 0 = null
data, _ = json.Marshal(i)
// 0 (not "null")
Package From("") Marshal null
opt Valid (empty string) null
opt/zero Invalid (null) ""

Comparison

Feature opt guregu/null *T sql.Null[T]
Generic Value[T] Full Partial N/A No JSON
Three-state (PATCH) Field[T] No No No
OrNull constructors Yes No No No
Map / FlatMap Yes No No No
OrElse (lazy) Yes No No No
JSON marshal Yes Yes Yes Broken
SQL Scanner/Valuer Yes Yes Yes Yes
omitzero (Go 1.24+) Yes Yes No No
json/v2 compatible Yes Yes Yes No
Legacy code None v1→v6 N/A N/A

Performance

Zero-allocation unmarshal. Bool operations under 2 nanoseconds:

BenchmarkBoolMarshalJSON       0.85 ns/op  0 allocs
BenchmarkBoolUnmarshalJSON     2.1 ns/op   0 allocs
BenchmarkIntUnmarshalJSON      193 ns/op   1 alloc
BenchmarkStringUnmarshalJSON   137 ns/op   0 allocs
BenchmarkStructMarshalJSON     876 ns/op   9 allocs

Why New Projects Should Start with opt

  • sql.Null[T] will never get JSON support - this is official Go team position (#68375)
  • *T can't do three-state - nil means both "absent" and "null"
  • guregu/null works but carries v1→v6 legacy and has no PATCH support
  • opt starts clean - Go 1.24+, generics-first, no backward compatibility burden
  • Field[T] is unique - no other Go library properly solves the PATCH three-state problem
  • Zero dependencies - only Go stdlib
  • json/v2 ready - works today, optimizable when json/v2 goes stable

Getting Started

go get github.com/coregx/opt@v0.2.0
import "github.com/coregx/opt"

// Always valid
name := opt.StringFrom("Alice")

// Zero means "not set"
age := opt.IntOrNull(0) // null

// From pointer (nil → null)
email := opt.StringFromPtr(emailPtr)

// Three-state for PATCH
type PatchRequest struct {
    Bio opt.Field[string] `json:"bio,omitzero"`
}

Full API and examples: github.com/coregx/opt

Help Us Get to v1.0.0

opt is in active development. We're heading toward a stable v1.0.0 and need your help:

  • Try it in your project - replace *string or guregu/null and tell us how it goes
  • Report issues - edge cases, driver compatibility, unexpected behavior
  • Suggest features - what nullable types should do that no library does yet
  • Send PRs - new types, better tests, documentation improvements
  • Share - if opt solved a problem for you, tell your team or write about it

Every bug report, feature idea, and PR brings us closer to a stable API.

GitHub | Issues | pkg.go.dev

opt is part of the coregx ecosystem - high-performance Go libraries for production applications.

Comments

No comments yet. Start the discussion.