Skip to content

Instantly share code, notes, and snippets.

@abitofhelp
Last active October 11, 2025 01:01
Show Gist options
  • Save abitofhelp/e8ae5bf1a21fba416bbe005264621488 to your computer and use it in GitHub Desktop.
Save abitofhelp/e8ae5bf1a21fba416bbe005264621488 to your computer and use it in GitHub Desktop.
Understanding Rust Lifetime Elision - Part 4

Understanding Rust Lifetime Elision - Part 4:

Structs, Lifetimes, and When Elision Doesn't Apply

Part 4 of 4: A comprehensive guide to mastering Rust's lifetime elision rules


Series Navigation


Recap: The Three Elision Rules

Before we explore the limits of lifetime elision, let's review what we've learned:

  1. Rule 1: Each reference parameter gets its own lifetime
  2. Rule 2: If there's exactly one reference input, all outputs get that lifetime
  3. Rule 3: In methods, return values get &self's lifetime (not other parameters')

These rules apply ONLY to function and method signatures. Now let's explore where elision does NOT work.


Part 1: Struct Fields Require Explicit Lifetimes

The most important principle: Struct field lifetimes must ALWAYS be explicitly declared. There is NO lifetime elision for struct definitions.

Why No Elision for Structs?

Structs are type definitions, not function signatures. They declare what lifetimes they need without any context about where those lifetimes come from. The compiler needs you to be explicit about these relationships.


Simple Struct Example - Single Lifetime

/// User session data container
///
/// # Fields
/// * `username` - Reference to username string with lifetime 'a
/// * `user_id` - User ID number (owned, no lifetime)
///
/// Lifetime 'a MUST be explicitly declared - no elision for struct fields.
struct UserSession<'a> {
    username: &'a str,    // Explicit lifetime required
    user_id: u32,         // No lifetime needed (owned)
}

Key points:

  • The <'a> after UserSession is required
  • Each reference field must use a declared lifetime
  • Owned fields (like u32) don't need lifetimes
  • This is a declaration, not elision-eligible code

Using the Struct in Functions

Even though the struct definition requires explicit lifetimes, functions that use the struct can still benefit from elision:

// Elision works here (Rule 2 applies)
fn create_session(name: &str, id: u32) -> UserSession {
    UserSession {
        username: name,
        user_id: id,
    }
}

// What the compiler sees
fn create_session_explicit<'a>(name: &'a str, id: u32) -> UserSession<'a> {
    UserSession {
        username: name,
        user_id: id,
    }
}

Important distinction:

  • Struct definition (struct UserSession<'a>) → explicit lifetimes required
  • Function using the struct (fn create_session) → elision can apply

Intermediate Struct Example - Multiple Lifetimes

Structs can have multiple independent lifetimes:

/// HTTP request data container
///
/// # Fields
/// * `method` - Reference to HTTP method string with lifetime 'a
/// * `path` - Reference to request path string with lifetime 'b
/// * `status_code` - HTTP status code (owned, no lifetime)
/// * `content_length` - Content length in bytes (owned, no lifetime)
///
/// Both lifetimes 'a and 'b MUST be explicitly declared.
struct HttpRequest<'a, 'b> {
    method: &'a str,          // Explicit lifetime 'a required
    path: &'b str,            // Explicit lifetime 'b required
    status_code: u16,         // No lifetime needed
    content_length: usize,    // No lifetime needed
}

Why multiple lifetimes? The method and path might come from different sources with different lifespans. By using separate lifetimes, we're telling the compiler they're independent.

Using Multiple Lifetimes in Functions

// Without elision (explicit)
fn parse_request<'a, 'b>(
    method_str: &'a str,
    path_str: &'b str,
    code: u16
) -> HttpRequest<'a, 'b> {
    HttpRequest {
        method: method_str,
        path: path_str,
        status_code: code,
        content_length: 0,
    }
}

Note: This function cannot use elision because:

  • It has two reference parameters (Rule 2 doesn't apply)
  • It's not a method (Rule 3 doesn't apply)
  • We must be explicit about which lifetime goes where

Complex Struct Example - Nested Structs

Structs can contain other structs with lifetimes:

/// Network packet header
///
/// # Fields
/// * `protocol` - Reference to protocol name with lifetime 'a
/// * `version` - Protocol version (owned, no lifetime)
///
/// Lifetime 'a MUST be explicitly declared.
struct PacketHeader<'a> {
    protocol: &'a str,    // Explicit lifetime required
    version: u8,          // No lifetime needed
}

/// Network packet with header and payload
///
/// # Fields
/// * `header` - PacketHeader with lifetime 'a
/// * `payload` - Reference to payload bytes with lifetime 'b
/// * `sequence_num` - Packet sequence number (owned, no lifetime)
/// * `timestamp` - Unix timestamp (owned, no lifetime)
///
/// Both lifetimes 'a and 'b MUST be explicitly declared.
struct NetworkPacket<'a, 'b> {
    header: PacketHeader<'a>,    // Explicit lifetime 'a required
    payload: &'b [u8],            // Explicit lifetime 'b required
    sequence_num: u64,            // No lifetime needed
    timestamp: i64,               // No lifetime needed
}

What's happening:

  • PacketHeader has its own lifetime 'a
  • NetworkPacket also needs lifetime 'a for its header field
  • NetworkPacket has an additional lifetime 'b for its payload field
  • This creates a hierarchy of lifetimes that must be explicitly tracked

Creating Nested Structs

// Must be explicit - multiple references, not a method
fn create_packet<'a, 'b>(
    protocol_name: &'a str,
    ver: u8,
    data: &'b [u8],
    seq: u64
) -> NetworkPacket<'a, 'b> {
    let header = PacketHeader {
        protocol: protocol_name,
        version: ver,
    };

    NetworkPacket {
        header,
        payload: data,
        sequence_num: seq,
        timestamp: 0,
    }
}

Part 2: When Elision Doesn't Apply to Functions

Even for functions, there are cases where you must write explicit lifetimes.

Case 1: Multiple Reference Inputs, Returning One of Them

// This will NOT compile without explicit lifetimes
// fn choose(first: &str, second: &str, use_first: bool) -> &str {
//     if use_first { first } else { second }
// }

// Compiler error: can't determine which input lifetime the output should have

// Must be explicit:
fn choose<'a>(first: &'a str, second: &'a str, use_first: bool) -> &'a str {
    if use_first { first } else { second }
}

Why elision fails: Two reference inputs, and we could return either one. The compiler can't guess which lifetime applies.

Case 2: Returning References Not Tied to Inputs

// This is a compile error - can't return reference to local
// fn create_string() -> &str {
//     let s = String::from("hello");
//     &s  // ERROR: `s` doesn't live long enough
// }

// This works - returning a reference to static data
fn get_constant() -> &'static str {
    "hello"
}

Key insight: If you're not returning a reference tied to an input parameter, you typically need 'static or another explicit lifetime.

Case 3: Complex Lifetime Relationships

struct Parser<'a> {
    text: &'a str,
}

impl<'a> Parser<'a> {
    // Need explicit lifetimes - we want to return a reference to
    // the parameter, not self
    fn first_or<'b>(&'b self, default: &'b str) -> &'b str
    where
        'a: 'b  // 'a must outlive 'b
    {
        if self.text.is_empty() {
            default
        } else {
            self.text
        }
    }
}

Advanced pattern: Using lifetime bounds ('a: 'b means 'a outlives 'b) to express complex relationships.

Case 4: Associated Types and Trait Implementations

trait Extractor {
    // Need explicit lifetime in trait
    fn extract<'a>(&'a self, data: &'a str) -> &'a str;
}

struct SimpleExtractor;

impl Extractor for SimpleExtractor {
    fn extract<'a>(&'a self, data: &'a str) -> &'a str {
        data
    }
}

Trait signatures often require explicit lifetimes because traits define contracts that must work across multiple implementations.


Part 3: Common Patterns and Best Practices

Pattern 1: Single Lifetime for Related Fields

When all reference fields in a struct come from the same source:

struct Document<'a> {
    title: &'a str,
    body: &'a str,
    author: &'a str,
}

// All fields share lifetime 'a - they come from the same source

Pattern 2: Multiple Lifetimes for Independence

When references come from different sources:

struct Config<'a, 'b> {
    app_name: &'a str,     // From command-line args
    file_path: &'b str,    // From config file
}

// Different lifetimes allow independent sources

Pattern 3: Owned + Borrowed Hybrid

Combine owned and borrowed data:

struct UserProfile<'a> {
    username: String,      // Owned - we control this
    bio: &'a str,          // Borrowed - references external data
    follower_count: u32,   // Owned primitive
}

Part 4: Debugging Lifetime Errors

Understanding Compiler Messages

When elision fails, the compiler tells you:

fn broken(x: &str, y: &str) -> &str {
    x
}

// Error message:
// this function's return type contains a borrowed value,
// but the signature does not say whether it is borrowed from `x` or `y`

Fix: Add explicit lifetimes to clarify:

fn fixed<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

Common Mistake: Returning References to Locals

// WRONG - returns reference to local variable
// fn create() -> &str {
//     let s = String::from("hello");
//     &s  // ERROR: s is dropped at end of function
// }

// RIGHT - return owned data
fn create() -> String {
    String::from("hello")
}

// OR - return 'static reference
fn create_static() -> &'static str {
    "hello"  // String literals have 'static lifetime
}

Summary: When to Use Explicit Lifetimes

Scenario Elision Works? Notes
Struct field declarations ❌ Never Always explicit
Single & parameter → & return ✅ Rule 2 Common pattern
Multiple & parameters → no return refs ✅ Rule 1 Just inputs
Method with &self& return ✅ Rule 3 Returns self data
Multiple & params → & return ❌ Usually not Ambiguous source
Returning ref to parameter (not self) ❌ No Must be explicit
Complex lifetime relationships ❌ No Use bounds like 'a: 'b
Trait definitions ⚠️ Sometimes Often need explicit

Final Key Takeaways

  1. Struct definitions ALWAYS require explicit lifetimes for reference fields
  2. Functions using structs can still benefit from elision (for their signatures)
  3. Elision is syntactic sugar - lifetimes are always there, just sometimes inferred
  4. When in doubt, be explicit - clarity beats brevity in complex cases
  5. The compiler is your friend - error messages guide you to solutions

Conclusion

You've now completed your journey through Rust's lifetime elision rules! You understand:

  • Rule 1: Each reference parameter gets its own lifetime
  • Rule 2: Single reference input flows to all outputs
  • Rule 3: Methods return &self's lifetime
  • Struct lifetimes: Always explicit, no elision
  • When elision fails: And how to fix it

What's Next?

Now that you understand lifetime elision, you're ready to:

  • Write idiomatic Rust code with minimal lifetime annotations
  • Understand compiler error messages about lifetimes
  • Design structs and APIs with appropriate lifetime parameters
  • Recognize when to be explicit for clarity

Further Reading


Final Practice Challenge

Identify what's wrong and fix these examples:

// 1. Does this compile?
struct Wrapper {
    data: &str,
}

// 2. Does this compile?
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

// 3. Does this compile?
fn first<'a>(x: &'a str, y: &str) -> &'a str {
    x
}
Click to see answers

1. No - Struct needs explicit lifetime:

struct Wrapper<'a> {
    data: &'a str,
}

2. No - Function needs explicit lifetime (two inputs, ambiguous output):

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

3. Yes! - This compiles. We explicitly declared that output has same lifetime as x. The y parameter can have a different lifetime since we're not returning it.


Series Navigation:


Thank you for reading this comprehensive series on Rust lifetime elision! I hope these articles help you write better, more idiomatic Rust code. Happy coding!


Author's Note: This series was created for educational purposes to help developers understand Rust's lifetime elision rules through comprehensive examples at multiple complexity levels. Feel free to share and use these materials to help others learn Rust!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment