Skip to content

Latest commit

 

History

History
757 lines (566 loc) · 15.1 KB

File metadata and controls

757 lines (566 loc) · 15.1 KB

Safe Navigation Operator (?.)

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.

Design Decision

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?

  1. Go lacks ternary - Can't write config == nil ? "" : config.ssl
  2. Zero overhead - Go compiler inlines these completely
  3. Type preservation - Return type matches the final field type
  4. Chainable - Multiple ?. in one expression

Works with both pointers and Option types - type checker determines which pattern to generate.

Why Safe Navigation?

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.

Basic Usage

Property Access

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)
    }
}

Method Calls

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)
}

Chaining

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.

Dual Type Support

Safe navigation works on two kinds of nullable types:

1. Option Types (Dingo's Type-Safe Approach)

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
}()

2. Raw Go Pointers (Interop with Go Stdlib)

// Go function returns *User (standard library pattern)
let user: *User = database.GetUser(123)
name := user?.name  // Works! Uses nil checks

What 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

Type Promotion Rules

When chaining safe navigation across mixed Option and pointer types, Dingo follows these type promotion rules:

Rule 1: Option Chains Stay Option

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).

Rule 2: Pointer Chains Stay Pointer

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.

Rule 3: Mixed Chains Promote to Option

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.

Rule 4: Type Inference Fallback

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

Type Promotion Table

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.

Real-World Examples

API Response Handling

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.

Database Queries

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
}

Configuration Access

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
}

Method Chaining with Transformations

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
}

Generated Go Code

Property Access (Option Type)

Dingo:

user?.address?.city

Generated 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
}()

Property Access (Pointer Type)

Dingo:

user?.address?.city

Generated Go:

func() *City {
    if user == nil {
        return nil
    }
    if user.address == nil {
        return nil
    }
    return user.address.city
}()

Method Call (Option Type)

Dingo:

user?.getName()

Generated Go:

func() Option[string] {
    if user.IsNone() {
        return Option[string]_None()
    }
    _user0 := user.Unwrap()
    return _user0.getName()
}()

Method with Arguments

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)

Edge Cases and Limitations

Trailing ?. (Error)

// ❌ Invalid - trailing ?. without property
result := user?.

// Error: trailing ?. operator without property

Empty Chain (Error)

// ❌ Invalid - ?. without left operand
result := ?.name

// Error: safe navigation requires base identifier

Mixed Pointer and Option Types

// user is *User (pointer), getSettings() returns SettingsOption
theme := user?.getSettings()?.theme

// Works! Result is Option[Theme]
// Nil converts to None at pointer→Option boundary

Generated 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:

  1. Pointer → Pointer → Pointer: Returns pointer
  2. Option → Option → Option: Returns Option
  3. Pointer → Option: Promotes to Option (safest)
  4. Option → Pointer: Keeps pointer, wraps at boundary

Multi-Line Chains (Not Yet Supported)

// ❌ Not supported in Phase 7
result := user?
    .address?
    .city?
    .name

// ✅ Workaround: Keep on one line
result := user?.address?.city?.name

Future enhancement planned.

Performance Considerations

Generated Code Size

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 .go files (minimal)
  • No runtime overhead (Go compiler inlines IIFEs)
  • Compilation speed unchanged

Runtime Performance

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.

Best Practices

1. Use with Null Coalescing for Defaults

// 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() {
    // ...
}

2. Prefer Option Types for New Code

// 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()

3. Keep Chains Readable

// 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.

4. Document None Cases

// 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 {
    // ...
}

5. Combine with Pattern Matching

result := user?.getSettings()

match result {
    Some(settings) => applySettings(settings),
    None => useDefaults()
}

Common Patterns

Safe Method Call with Fallback

name := user?.getName() ?? "Guest"

Optional Property Access

timeout := config?.database?.timeout ?? 30

Nested Optional Transformation

email := user?.getProfile()?.email?.toLowerCase()

Validation Chain

valid := input?.trim()?.validate()?.isOk() ?? false

Migration from Go

Before (Go)

func 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
}

After (Dingo)

func getUserCity(user: *User) string {
    return user?.address?.city ?? "Unknown"
}

Benefits:

  • 80% less code
  • No copy-paste errors
  • Intent is obvious
  • Same safety guarantees

Integration with Other Features

With Pattern Matching

match user?.getRole() {
    Some("admin") => grantAdminAccess(),
    Some("user") => grantUserAccess(),
    None => denyAccess()
}

With Error Propagation

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})
}

With Null Coalescing

// Covered in null-coalescing.md
theme := user?.settings?.theme ?? config?.defaultTheme ?? "light"

See Also

Resources