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
OrNullconstructors - every project writes the same boilerplate helpers - Generic
Value[T]hasMarshalTextandEqualcommented 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)*Tcan't do three-state - nil means both "absent" and "null"guregu/nullworks 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
*stringorguregu/nulland 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.