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/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
*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.
Top comments (0)