Skip to content

[pre-RFC] errdefer for try blocks #3901

@KmolYuan

Description

@KmolYuan

Proposal

This pre-RFC proposes a new syntactic construct, tentatively named errdefer, inspired by Zig’s errdefer, to associate error-only cleanup logic with a try block.

Since we have a try block expression (#3721) without an "Ok" wrapped result (rust-lang/rust#70941) to distinguish the "errors" to spawn, we can have a block to handle the duplicated cleanup logics during error handling.

The syntax introduces a trailing block:

let result = try {
    /* main logic */
} errdefer {
    /* cleanup logic */
};

The errdefer block is executed if and only if the try block does not produce a successful value; basically, the value is the last expression of the try block.

Motivation

The recently introduced try { ... } block expression (#3721) allows structured error propagation without immediately wrapping values in Ok. However, it currently provides no mechanism to express error-path-specific cleanup logic in a structured and borrow-checker-friendly manner.

In real-world code, especially when interacting with external resources or undergoing multi-step state transitions, cleanup logic often needs to run only when a try block does not complete successfully, i.e., when it exits due to error propagation or early control flow (return, break, or continue). Today, this typically requires:

  • check after returned,
  • ad-hoc RAII guards,
  • or duplicated cleanup code across error branches.

These approaches either reduce readability or interact poorly with borrowing and move semantics.

For example:

// Check after returned
{
    let result = self.foo();
    if result.is_err() {
        /* Break the relation of `foo` */
        self.foo_cleanup();
    }
    result
}

// RAII guards
{
    let guard = ErrDefer::new(|| {
        // Register a drop function that runs when an error returns
    });

    foo()?; // execute drop function

    guard.assume_ok(); // consume and cancel the drop function
    Ok(ok_value)
}

Semantics

This syntax can be roughly desugared to inlining the errdefer blocks at the break points.

try {
    foo()?
} errdefer {
    bar();
    baz();
}

will be the same semantics to

try {
    match Try::branch(foo()) {
        ControlFlow::Continue(v) => v,
        ControlFlow::Break(r) => {
            { // past or call the `errdefer` block here
                bar();
                baz();
            }
            return /* try block desugar with `r` */;
        }
    }
}
  • The errdefer block is executed when control flow exits the associated try block without producing its success value, including:
    • error propagation via ? operators
    • early exits via return, break, or continue.
  • The errdefer block is executed before local variables of the try block are dropped.
  • The errdefer block is not executed if the try block completes successfully at the last expression of the try block.
  • The errdefer block does not handle the unwind logic. It won't execute when the try block panics.
  • The errdefer block is lexically associated with the try block and has access to bindings that are guaranteed to be live at all error exit points. So errdefer won't borrow anything before errors, unlike RAII guards.
  • The errdefer block should have a unit value () at the last expression, or no expression at the end, since it only deals with the cleanup logic, not changing return values.
  • Different from the try blocks, ? operators in the errdefer block belong to the super scope, just as the return/break/continue.
  • Both homogeneous and heterogeneous try blocks can have an errdefer block.
  • If a try block has no error or early return branches, its errdefer block will be considered as dead code and unreachable.

Example:

let x;
/* ... */
let result = try {
    let y;
    /* ... */
    let f_moved = foo(&y)?;

    if cond {
        let cond_local;
        /* ... */
        return;
    }

    bar(f_moved)?
} errdefer {
    // `return` & `?` branches are going to the `errdefer` block
    // we can use `x`, `y`, and the variables came from the outer scope
    self.unregist(&y);
    self.channel_a.off().unwrap();
    self.channel_b.off()?; // not related to the `result`, which is the super scope things
};

Early return statements

The return, break, and continue break the try block into its super scope. We currently can not distinguish the false-positive cases, such as return Ok(()); of the super scope. Therefore, this RFC conservatively assumes that this usually represents a mishandling behavior.

// loop {
try {
    let Some(x) = x_wrapped else {
        // May not be an error,
        // false positive here, still enter `errdefer` block.
        // Needs to be moved outside
        continue;
    };
} errdefer { /* ... */ }

Nested try and errdefer

errdefer blocks may contain try expressions with their own errdefer blocks. Each errdefer is isolated from others; the semantics are the same.

try { /* ... */ } errdefer {
    // `try` expression cannot be placed at the last expression of the `errdefer` block
    let result = try {
        cleanup1()?;
        cleanup2()?;
    } errdefer {
        eprintln!("error during cleanup");
    };
}

Can errdefer be added to other expressions or functions?

No, because only the try blocks can distinguish between the success value and the error values. This is thanks to the no-Ok/no-Some wrapping.

Future possibilities

Multiple success branches

If there are break try $expr statements, the errdefer block can also handle multiple success branches just like the single one.

try {
    if cond {
        break try foo()?; // Ok-wrapped
    }
    /* ... */
    bar()?
}

Multiple errdefer blocks

For more precise error handling, the errdefer block may specify a local variable in the try block, and only execute when this variable is accessible. The execution is ordered as they are defined.

try {
    let x = foo1()?;
    foo2(&x)?;
    let y;
    foo3()?;
    foo4(x)?
} errdefer x {
    /* error on foo2 & foo3 will enter this branch */
} errdefer (x, y) {
    /* error on foo3 will enter this branch */
} errdefer {
    /* For all cases */
}

But the shadowed variables need to define a syntax to "match" them, and the types cannot be wildcard _:

try {
    let x: i32;
    let x: u32;
    let y: ();
} errdefer x ☃️ i32 {
    /* ... */
} errdefer (x, y) ☃️ (u32, ()) {
    /* ... */
}

try fn with errdefer block

If we have try fn functions, which have a try-wrapped function body, we can add errdefer as well.

try fn send(&mut self, doc: &[u8]) -> std::io::Result<usize> {
    /* main logic */
    self.buf.write(doc)?;
    self.buf.cursor_pos()
} errdefer {
    /* cleanup logic */
    self.buf
        .try_close()
        .inspect_err(|e| eprintln!("Error when close: {e}"))
        .ok();
}

Closures are fine since they have formatting benefits on return-position blocks.

let f = || try {
    /* ... */
} errdefer {
    /* ... */
};

A similar defer block syntax

In Zig, defer affects both error and non-error cleanup code.

Since the cleanup code can be simply added after the try blocks or use RAII guards, if we have it, it is just a syntax sugar and can be added to any scoped blocks, not limited to try blocks, which can be confusing with today's Drop impls.

Open Questions

  • Final keyword choice? (errdefer, else, final, etc.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions