Skip to content

Repository files navigation

safechain logo

Go Reference Go Report Card CI Coverage Release License Stars

Nil-safe access to deeply nested struct pointer chains in Go.
No more if a != nil && a.B != nil && a.B.C != nil boilerplate.

InstallationQuick StartAPIBenchmark中文文档


The Problem

Go has no optional chaining. Accessing req.A.B.C.D requires checking every pointer:

var token string
if req != nil && req.Auth != nil && req.Auth.Key != nil &&
    req.Auth.Key.Session != nil && req.Auth.Key.Session.Token != nil {
    token = *req.Auth.Key.Session.Token
}

safechain eliminates this boilerplate with two approaches:

Approach Use When Overhead
Safe / Must / OrVal Don't need to know which field is nil ~4 ns, 0 alloc
Dig + S() Need precise error: which field was nil ~190 ns/100 depth, 0 alloc
Ensure + Set Build/assign deeply nested structs without nil checks ~4 ns, 0 alloc

Installation

go get github.com/mredencom/safechain

Quick Start

import "github.com/mredencom/safechain"

// Read — returns zero value if any pointer is nil
token := safechain.Must(func() string {
    return *req.Auth.Key.Session.Token
})

// Read — with fallback
token := safechain.OrVal(func() string {
    return *req.Auth.Key.Session.Token
}, "N/A")

// Read — comma-ok style
token, ok := safechain.Safe(func() string {
    return *req.Auth.Key.Session.Token
})

// Write — build nested struct and assign, no nil checks
var req Request
safechain.E(&safechain.E(&safechain.E(&req.Auth).Key).Session).Token = ptr("my_token")

API

Core — recover-based (most commonly used)

// Returns (value, ok) — use * to dereference pointer fields like *string
val, ok := Safe(func() string { return *req.Auth.Key.Session.Token })

// Returns value or zero
val := Must(func() string { return *req.Auth.Key.Session.Token })

// Returns value or fallback
val := OrVal(func() string { return *req.Auth.Key.Session.Token }, "N/A")

// For non-pointer fields (e.g. int), no * needed
count, ok := Safe(func() int { return req.Auth.Key.Session.RetryCount })

Logic — And / Or / Any / Not / None / Count / AtLeast / NotNil

// ALL conditions must be true
ok := And(
    Check(func() { _ = *req.Auth.Key.Session.Token }),
    HasPrefix(func() string { return *req.Auth.Key.Session.Token }, "Bearer"),
    Gt(func() int { return *req.Auth.RetryCount }, 0),
)

// At least ONE condition is true (Or is an alias for Any)
ok := Or(
    Check(func() { _ = *req.Auth.Key.Session.Token }),
    Check(func() { _ = *req.Fallback }),
)

// Negate a condition
ok := And(
    Check(func() { _ = *req.Auth.Key.Session.Token }),
    Not(HasPrefix(func() string { return *req.Auth.Key.Session.Token }, "Bearer")),
)

// ALL conditions must be false
ok := None(
    Check(func() { _ = *req.BannedToken }),
    Check(func() { _ = *req.ExpiredToken }),
)

// Count how many conditions are true
n := Count(
    Check(func() { _ = *req.Auth.Key.Session.Token }),
    Check(func() { _ = *req.Fallback }),
    Check(func() { _ = *req.Meta.TraceID }),
)

// At least N conditions must be true
ok := AtLeast(2,
    Check(func() { _ = *req.Auth.Key.Session.Token }),
    Check(func() { _ = *req.Fallback }),
    Check(func() { _ = *req.Meta.TraceID }),
)

// NotNil — simplified nil check, no need for _ = or *
ok := NotNil(func() any { return req.Auth.Key.Session })

Coalesce — First / MustFirst

// First non-nil value wins (like SQL COALESCE)
token := MustFirst(
    func() string { return *req.Auth.Key.Session.Token },
    func() string { return *req.Fallback },
    func() string { return "anonymous" },
)

Comparison — Eq / Ne / Gt / Gte / Lt / Lte / Between

Use * to dereference pointer fields (e.g. *string, *int). For value fields (e.g. int, string), no * needed.

// *r.A.Name — Name is *string, needs *
Eq(func() string { return *r.A.Name }, "admin")
Ne(func() string { return *r.A.Name }, "guest")

// *r.A.Score — Score is *int, needs *
Gt(func() int { return *r.A.Score }, 10)
Gte(func() int { return *r.A.Score }, 10)
Lt(func() float64 { return *r.A.Rate }, 3.14)
Lte(func() float64 { return *r.A.Rate }, 3.14)
Between(func() int { return *r.A.Score }, 1, 100)

// r.A.Count — Count is int (value type), no * needed
Gt(func() int { return r.A.Count }, 0)

// Interval variants
BetweenExcl(func() int { return *r.A.Score }, 0, 100)   // (0, 100)  open
BetweenLExcl(func() int { return *r.A.Score }, 0, 100)  // (0, 100]  left-open
BetweenRExcl(func() int { return *r.A.Score }, 0, 100)  // [0, 100)  right-open

// Custom predicate
Match(func() string { return *r.A.Name }, func(v string) bool { return len(v) > 3 })

String matchers

HasPrefix(func() string { return *r.A.Name }, "hello")
HasSuffix(func() string { return *r.A.Name }, "world")
Contains(func() string { return *r.A.Name }, "llo_wor")
EqFold(func() string { return *r.A.Name }, "HELLO")
MatchRegexp(func() string { return *r.A.Name }, `^\d+$`)
MatchRegexpCompiled(func() string { return *r.A.Name }, re)  // pre-compiled, for hot paths

[]byte matchers

BytesHasPrefix(func() []byte { return *r.A.Data }, []byte("hello"))
BytesHasSuffix(func() []byte { return *r.A.Data }, []byte("world"))
BytesContains(func() []byte { return *r.A.Data }, []byte("llo"))
BytesEq(func() []byte { return *r.A.Data }, []byte("exact"))
BytesMatchRegexp(func() []byte { return *r.A.Data }, `\d+`)
BytesMatchRegexpCompiled(func() []byte { return *r.A.Data }, re)

SafeDig — named chain with precise error reporting

No unsafe.Pointer needed. Use F() to define each step:

// MustSafeDig — just check existence, returns bool
ok := MustSafeDig(req,
    F("Auth", func(r *Request) any { return r.Auth }),
    F("Key", func(a *Auth) any { return a.Key }),
    F("Session", func(k *Key) any { return k.Session }),
)

// SafeDig — get value + bool
token, ok := SafeDig[string](req,
    F("Auth", func(r *Request) any { return r.Auth }),
    F("Key", func(a *Auth) any { return a.Key }),
    F("Session", func(k *Key) any { return k.Session }),
    F("Token", func(s *Session) any { return s.Token }),
)

// SafeDigErr — get value + *NilError pinpointing the nil field
token, err := SafeDigErr[string](req,
    F("Auth", func(r *Request) any { return r.Auth }),
    F("Key", func(a *Auth) any { return a.Key }),
)
// err: nil pointer at field "Key" in path "Auth.Key"

Dig — zero-alloc advanced version (unsafe.Pointer)

For hot paths where allocation matters:

import "unsafe"

val, err := safechain.Dig[string](req,
    safechain.S("Auth", func(r *Request) unsafe.Pointer { return unsafe.Pointer(r.Auth) }),
    safechain.S("Key", func(a *Auth) unsafe.Pointer { return unsafe.Pointer(a.Key) }),
    safechain.S("Session", func(k *Key) unsafe.Pointer { return unsafe.Pointer(k.Session) }),
    safechain.S("Token", func(s *Session) unsafe.Pointer { return unsafe.Pointer(s.Token) }),
)

Composing with And / Or

All comparison and matcher functions return bool, so they plug directly into And/Or:

ok := And(
    HasPrefix(func() string { return *req.Auth.Key.Session.Token }, "Bearer"),
    Gt(func() int { return *req.Auth.RetryCount }, 0),
    Check(func() { _ = *req.Meta.TraceID }),
)

Transform — Map / MustMap

// Safely get a value and transform it
upper, ok := Map(func() string { return *r.A.Name }, strings.ToUpper)
upper := MustMap(func() string { return *r.A.Name }, strings.ToUpper)
length := MustMap(func() string { return *r.A.Name }, func(s string) int { return len(s) })

Set membership — In / NotIn

In(func() string { return *r.A.Role }, "admin", "superadmin")
NotIn(func() string { return *r.A.Role }, "banned", "suspended")

Zero value — IsZero / NotZero

IsZero(func() string { return *r.A.Name })    // *string: "" → true, "abc" → false
NotZero(func() int { return r.A.Count })       // int (value type): 0 → false, 5 → true

Length — Len / MustLen

n, ok := Len(func() string { return *r.A.Name })
n := MustLen(func() []byte { return *r.A.Data })

Side effect — IfOk

IfOk(func() string { return *r.A.Token }, func(token string) {
    fmt.Println("got token:", token)
})

Error — SafeErr

// Like Safe but returns error instead of bool
val, err := SafeErr(func() string { return *r.A.Name })
// err: "nil pointer dereference: runtime error: ..."

Write — E / Ensure / Set / SetErr

Build out deeply nested structs without nil checks. E() (short for Ensure) auto-allocates nil pointer fields and returns the value, enabling one-liner chain assignment.

// E(): one-liner chain — auto-create all intermediate pointers and assign
var req Request
E(&E(&E(&req.Auth).Key).Session).Token = ptr("my_token")

// Works with value-type fields too — just chain to the parent
E(&E(&req.Auth).Key).Name = "admin"     // string field
E(&E(&req.Auth).Key).Score = 100        // int field

// Grab a reference to avoid repeating the chain
key := E(&E(&req.Auth).Key)
key.Name = "admin"
key.Score = 100
key.Token = ptr("abc")

// Set: chain + assign with panic recovery, returns bool
ok := Set(func() **string {
    return &E(&E(&E(&req.Auth).Key).Session).Token
}, ptr("my_token"))

// SetErr: like Set but returns error on failure
_, err := SetErr(func() **string {
    return &E(&E(&E(&req.Auth).Key).Session).Token
}, ptr("my_token"))
// err: "set failed: runtime error: invalid memory address..."

Benchmark

Tested on Apple M4 (10 cores), Go 1.22+.

Single-goroutine — per function (4-level struct)

Function ns/op allocs
Safe 7.0 0
Must 8.0 0
OrVal 8.3 0
Check 8.9 0
NotNil 8.2 0
Eq 9.1 0
Gt 7.8 0
Between 8.2 0
In (3 values) 13 0
Match 9.4 0
HasPrefix 12 0
Contains 13 0
Map 9.5 0
First 9.7 0
SafeErr 7.0 0
And (2 checks) 18 0
SafeDig (4 levels) 141 5
MustSafeDig (3 levels) 110 4
Dig (4 levels) 45 1

Parallel — concurrent throughput (10 goroutines)

Function ns/op allocs
Safe 2.2 0
Must 2.2 0
Check 2.5 0
Eq 2.3 0
HasPrefix 2.8 0
In 3.2 0
Dig (4 levels) 43 1
SafeDig (4 levels) 102 5

Deep nesting — 100 levels

Approach Single Parallel Allocs
Manual if != nil 93 ns 16 ns 0
Safe (recover) 88 ns 18 ns 0
Dig (unsafe.Pointer) 589 ns 718 ns 1
  • Safe matches hand-written nil checks at any depth, zero allocation.
  • All recover-based functions scale linearly with goroutines — no contention.
  • Dig/SafeDig have allocation overhead from the names slice, but still sub-microsecond.

Concurrency

All functions are goroutine-safe — no shared state, all operations are stack-local. Verified with 1000-goroutine stress tests + -race detector on every public function:

Safe, Must, OrVal, Check, NotNil, And, Or, Eq, Gt, Between, In, Match, HasPrefix, Contains, Map, First, SafeErr, Dig, SafeDig, MustSafeDig

The only requirement is that the struct being accessed is not concurrently modified by another goroutine (same as hand-written if != nil).

Requirements

  • Go 1.22+

License

MIT

About

Nil-safe deep struct access for Go — no more nil pointer panics.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages