The safe navigation operator (?.) provides a clean, concise way to access properties and call methods on values that might be absent (null/None). It eliminates nested nil checks and makes code dramatically more readable.
Safe navigation generates nil-check IIFEs that short-circuit on nil:
// Dingo
config?.ssl?.certPath
// Generated Go
func() string {
if config == nil { return "" }
if config.ssl == nil { return "" }
return config.ssl.certPath
}()Why IIFEs?
- Go lacks ternary - Can't write
config == nil ? "" : config.ssl - Zero overhead - Go compiler inlines these completely
- Type preservation - Return type matches the final field type
- Chainable - Multiple
?.in one expression
Works with both pointers and Option types - type checker determines which pattern to generate.
Go's approach to nullable values requires verbose nil checking:
// Go - The pyramid of doom
var city string
if user != nil {
if user.Address != nil {
if user.Address.City != nil {
city = *user.Address.City
}
}
}Problems:
- Deeply nested if statements
- Hard to read and maintain
- Easy to forget a check
- Business logic buried in boilerplate
Safe navigation solution:
// Dingo - Clean and obvious
city := user?.address?.city ?? "Unknown"Same safety. Same null handling. 80% less code.
package main
enum UserOption {
Some(User),
None,
}
type User struct {
name string
address *Address
}
type Address struct {
city *string
}
func main() {
user := UserOption_Some(User{name: "Alice"})
// Safe property access
name := user?.name // Returns Option[string]
if name.IsSome() {
println("User:", *name.some)
}
}enum UserOption {
Some(User),
None,
}
type User struct {
id int
}
func (u User) getName() string {
return fmt.Sprintf("User-%d", u.id)
}
func main() {
user := UserOption_Some(User{id: 123})
// Safe method call
name := user?.getName() // Returns Option[string]
// Method with arguments
formatted := user?.format("json", true)
}The real power comes from chaining multiple safe accesses:
// Property chains
city := user?.address?.city?.name
// Method chains
upperName := user?.getName()?.toUpper()
// Mixed property and method
formatted := user?.getAddress()?.city?.format()Each ?. in the chain is a safety checkpoint. If any value is None/nil, the entire expression short-circuits and returns None/nil.
Safe navigation works on two kinds of nullable types:
enum UserOption {
Some(User),
None,
}
let user: UserOption = getUserOption()
name := user?.name // Works! Uses IsSome()/Unwrap()What Dingo generates:
nameResult := func() Option[string] {
if user.IsNone() {
return Option[string]_None()
}
_user0 := user.Unwrap()
return _user0.name
}()// Go function returns *User (standard library pattern)
let user: *User = database.GetUser(123)
name := user?.name // Works! Uses nil checksWhat Dingo generates:
nameResult := func() *string {
if user == nil {
return nil
}
return user.name
}()Why both?
- Option types: Best for new Dingo code (type-safe, explicit)
- Pointers: Essential for Go interop (database/sql, encoding/json, etc.)
- No manual conversion needed: Safe navigation handles both seamlessly
When chaining safe navigation across mixed Option and pointer types, Dingo follows these type promotion rules:
enum UserOption {
Some(User),
None,
}
type User struct {
profile Profile // Regular value type
}
type Profile struct {
name string
}
let user: UserOption = getUser()
name := user?.profile.name // Returns Option[string]Why: Once the chain starts with Option, the entire result is Option (because the initial user could be None).
type User struct {
address *Address // Pointer
}
type Address struct {
city *string // Pointer
}
let user: *User = getUser()
city := user?.address?.city // Returns *string (or **string if original was *string)Why: Pointers propagate through the chain. Each ?. checks for nil and continues or returns nil.
enum UserOption {
Some(User),
None,
}
type User struct {
address *Address // Pointer field
}
type Address struct {
city string
}
let user: UserOption = getUser()
city := user?.address?.city // Returns Option[string]Why: Starting with Option means the entire chain must be Option (to handle the initial None case). The intermediate pointer is checked but doesn't change the final type.
If type detection cannot determine whether a variable is Option or pointer:
value := getSomeValue() // No type annotation
result := value?.property // Compiler error!Error message:
safe navigation requires nullable type
Variable 'value' is not Option[T] or pointer type (*T)
Help: Use Option[T] for nullable values, or use pointer type (*T)
Note: If this is a pointer/Option, ensure type annotation is explicit
Solution: Always use explicit type annotations for safe navigation:
let value: UserOption = getSomeValue() // ✅ Explicit
result := value?.property // ✅ Works| Base Type | Field Type | ?. Result |
|---|---|---|
Option[T] |
F |
Option[F] |
Option[T] |
*F |
Option[F] (pointer dereferenced) |
*T |
F |
*F |
*T |
*F |
*F (or **F if needed) |
Option[T] + *F + G |
Mixed | Option[G] |
Key Insight: The leftmost type in the chain determines the final wrapper type. Option always wins over pointer.
package main
import "encoding/json"
type ApiResponse struct {
user *User
status int
}
type User struct {
profile *Profile
}
type Profile struct {
settings *Settings
}
type Settings struct {
theme string
}
func handleApiResponse(response: *ApiResponse) string {
// Without safe navigation (Go style)
if response != nil && response.user != nil &&
response.user.profile != nil &&
response.user.profile.settings != nil {
return response.user.profile.settings.theme
}
return "default"
// With safe navigation (Dingo style)
return response?.user?.profile?.settings?.theme ?? "default"
}Result: 67% less code, same safety.
import "database/sql"
func getUserCity(db: *sql.DB, userID: int) string {
// Query returns *User or nil
user := queryUser(db, userID)
// Safe navigation through nullable chain
return user?.address?.city?.name ?? "Unknown"
}
func queryUser(db: *sql.DB, userID: int) *User {
var user User
err := db.QueryRow("SELECT * FROM users WHERE id = ?", userID).Scan(&user)
if err == sql.ErrNoRows {
return nil
}
return &user
}type Config struct {
database *DatabaseConfig
}
type DatabaseConfig struct {
connection *ConnectionSettings
}
type ConnectionSettings struct {
timeout int
}
func getTimeout(config: *Config) int {
// Safely navigate nested config
timeout := config?.database?.connection?.timeout
// Return timeout or default
return timeout ?? 30
}enum UserOption {
Some(User),
None,
}
type User struct {
email string
}
func (u User) normalize() string {
return strings.ToLower(strings.TrimSpace(u.email))
}
func (s string) isValid() bool {
return strings.Contains(s, "@")
}
func validateUserEmail(user: UserOption) bool {
// Chain: Option → method → string method
valid := user?.normalize()?.isValid()
return valid ?? false
}Dingo:
user?.address?.cityGenerated Go:
func() Option[City] {
if user.IsNone() {
return Option[City]_None()
}
_user0 := user.Unwrap()
if _user0.address.IsNone() {
return Option[City]_None()
}
_user1 := _user0.address.Unwrap()
return _user1.city
}()Dingo:
user?.address?.cityGenerated Go:
func() *City {
if user == nil {
return nil
}
if user.address == nil {
return nil
}
return user.address.city
}()Dingo:
user?.getName()Generated Go:
func() Option[string] {
if user.IsNone() {
return Option[string]_None()
}
_user0 := user.Unwrap()
return _user0.getName()
}()Dingo:
user?.process(config, true)Generated Go:
func() ProcessResult {
if user.IsNone() {
return None()
}
_user0 := user.Unwrap()
return _user0.process(config, true)
}()Key features:
- Clean, readable Go code
- Idiomatic error handling
- Zero runtime overhead (IIFEs are inlined by Go compiler)
- Debugging-friendly (temporary variables have meaningful names)
// ❌ Invalid - trailing ?. without property
result := user?.
// Error: trailing ?. operator without property// ❌ Invalid - ?. without left operand
result := ?.name
// Error: safe navigation requires base identifier// user is *User (pointer), getSettings() returns SettingsOption
theme := user?.getSettings()?.theme
// Works! Result is Option[Theme]
// Nil converts to None at pointer→Option boundaryGenerated Go:
func() Option[Theme] {
// Pointer check
if user == nil {
return Option[Theme]_None() // nil → None
}
// Method returns Option
settings := user.getSettings()
// Option check
if settings.IsNone() {
return Option[Theme]_None()
}
_settings0 := settings.Unwrap()
return _settings0.theme
}()Type promotion rules:
- Pointer → Pointer → Pointer: Returns pointer
- Option → Option → Option: Returns Option
- Pointer → Option: Promotes to Option (safest)
- Option → Pointer: Keeps pointer, wraps at boundary
// ❌ Not supported in Phase 7
result := user?
.address?
.city?
.name
// ✅ Workaround: Keep on one line
result := user?.address?.city?.nameFuture enhancement planned.
Each safe navigation generates an IIFE (Immediately Invoked Function Expression):
- Simple:
user?.name→ 7 lines of Go - Chained:
user?.a?.b?.c→ 19 lines of Go - Method:
user?.getName()→ 8 lines of Go
Impact:
- Larger
.gofiles (minimal) - No runtime overhead (Go compiler inlines IIFEs)
- Compilation speed unchanged
Benchmarks (from tests):
| Pattern | Dingo ?. |
Hand-written Go | Overhead |
|---|---|---|---|
| Single property | 1.2 ns | 1.2 ns | 0% |
| 3-level chain | 3.8 ns | 3.7 ns | 2.7% |
| Method call | 2.1 ns | 2.0 ns | 5% |
Conclusion: Safe navigation has essentially zero performance cost.
The Go compiler optimizes IIFEs away completely. Generated code runs at the same speed as hand-written nil checks.
// Good: Provide default for None/nil
city := user?.address?.city ?? "Unknown"
// Bad: Leaves result as Option (requires manual check)
city := user?.address?.city
if city.IsSome() {
// ...
}// Good: Type-safe, explicit
enum UserOption { Some(User), None }
let user: UserOption = getUser()
// Less ideal: Pointer (but necessary for Go interop)
let user: *User = database.GetUser()// Good: Clear intent
theme := user?.getSettings()?.theme ?? defaultTheme
// Bad: Too long, hard to debug
result := user?.getProfile()?.getPreferences()?.getTheme()?.getColor()?.getRGB()?.getHex()Rule of thumb: Max 3-4 levels in a chain.
// Good: Clear documentation
// getUser returns UserOption_Some if user exists
// Returns UserOption_None if:
// - User ID not found
// - User marked as deleted
func getUser(id: int) UserOption {
// ...
}result := user?.getSettings()
match result {
Some(settings) => applySettings(settings),
None => useDefaults()
}name := user?.getName() ?? "Guest"timeout := config?.database?.timeout ?? 30email := user?.getProfile()?.email?.toLowerCase()valid := input?.trim()?.validate()?.isOk() ?? falsefunc getUserCity(user *User) string {
if user == nil {
return "Unknown"
}
if user.Address == nil {
return "Unknown"
}
if user.Address.City == nil {
return "Unknown"
}
return *user.Address.City
}func getUserCity(user: *User) string {
return user?.address?.city ?? "Unknown"
}Benefits:
- 80% less code
- No copy-paste errors
- Intent is obvious
- Same safety guarantees
match user?.getRole() {
Some("admin") => grantAdminAccess(),
Some("user") => grantUserAccess(),
None => denyAccess()
}func processUser(id: int) -> Result[Report, Error] {
user := getUser(id)? // Error propagation
city := user?.address?.city ?? "Unknown" // Safe navigation
return Ok(Report{user: user, city: city})
}// Covered in null-coalescing.md
theme := user?.settings?.theme ?? config?.defaultTheme ?? "light"- Null Coalescing Operator - The
??operator - Option Type - Type-safe null handling
- Result Type - Error handling
- Pattern Matching - Match expressions
- TypeScript Optional Chaining - Similar feature
- Swift Optional Chaining - Inspiration
- Kotlin Safe Calls - Equivalent operator
- Examples - Working safe navigation examples