[Discussion] A perspective on super let: could a related “lift” capability ever belong in Rust’s macro system?

Hi everyone,

As I’ve been closely following the exciting progress of #139076 (super let), I want to share a perspective inspired by the macro systems of Lisp/Racket. While super let beautifully solves the temporary lifetime and hygiene issues, I’ve been wondering if this capability could—or should—be generalized as a feature of the Rust macro system itself.

1. The Core Observation: super let is essentially a hygienic AST Lift

If we look strictly at the Borrow Checker rules, super let doesn't actually introduce new lifetime semantics. It essentially performs two tasks:

  1. Relocation (Drop Scope) : It hoists the variable declaration to the outer scope to extend its extent/survival time.

  2. Hygiene (Lexical Scope) : It locks the visibility of the variable strictly to the inner block, preventing external access.

In other words, super let is a mechanism to "hygienically leak a variable's drop scope to the outside without leaking its lexical visibility."

This is also why it seems closely related to temporary lifetime extension: in that case, the compiler already performs a limited, implicit version of this idea for certain syntactic forms, effectively placing an intermediate temporary in a scope where the resulting borrow can remain valid. From this perspective, super let looks like a way to make that kind of placement explicit and programmable, while preserving hygiene.

2. The Racket Inspiration: syntax-local-lift-expression

In the Racket/Scheme macro ecosystem, this exact problem is solved without introducing core language keywords. Instead, the macro system is empowered with a lift capability (e.g., syntax-local-lift-expression).

When a macro in Racket needs to generate an expression that should be evaluated/stored in an outer context (while keeping its identifier perfectly hygienic locally), the macro "lifts" it. The macro expander physically moves the declaration to the outer scope, but leaves behind a hygienic identifier for the local code to use.

3. What if Rust Macros had lift capabilities?

If Rust's macro system (e.g., Proc-macros) were extended to support lift, macros could natively solve the temporary lifetime issues. For example, a macro like pin!() or format_args!() could use a hypothetical TokenStream::lift() API. The macro would tell the compiler: "Please place this generated temporary variable in the parent block's scope, but only give me back the hygienic identifier to use in this local expansion."

Potential advantages of this approach:

  • More Powerful Metaprogramming : Macros could conditionally lift various constructs, offering developers more flexibility without being limited to let bindings.

  • Keeping Core Language Smaller : It keeps the complexity inside the metaprogramming domain rather than adding a new feature (super) to the core language. After all, the temporary lifetime issue is mostly felt when authoring or using macros. In ordinary day-to-day code, I think this issue is often not catastrophic.

4. The Trade-offs

I understand that Rust and Racket have fundamentally different architectures. I can see a few reasons why super let might be preferred over macro lift:

  1. In-place Expansion : Rust macros currently operate on strict in-place token replacement. Modifying the AST outside the macro's invocation span would require a massive architectural shift in the compiler's expansion engine.

  2. Everyday Ergonomics : super let isn't just for macro authors. It dramatically improves the ergonomics for regular developers facing "delayed initialization" patterns in daily coding.

  3. Explicitness : Rust values explicit control flow. super let makes the extend ed drop scope visibly explicit to the reader, whereas a macro lift would hide this side-effect.

5. Summary and Questions

My current intuition is that super let is best understood not as “lifetime magic,” but as an explicit, hygienic variable-placement mechanism. In that sense, it feels closely connected to temporary lifetime extension, except that it turns an implicit and syntax-restricted behavior into something explicit and controllable.

That in turn makes me wonder whether super let might be viewed as one specialized language construct for a more general capability: hygienically placing a generated binding in an outer drop scope while keeping its lexical visibility local.

So I’d be very interested to hear the lang/compiler team’s thoughts on a few questions:

  1. Was a macro-level “lift” capability ever considered during the early design discussions around this space?
  2. Is there a fundamental reason this kind of outward placement should remain a core-language feature rather than something macros could also express?
  3. Even if super let is the right design for Rust, could a related lift-like capability ever make sense for future macro evolution?

Looking forward to hearing your thoughts!

1 Like

Looking back at Scheme every so often when pondering the design of macro-related functionality certainly can’t hurt. For this syntax-local-lift-expression you’re mentioning, docs seem to be here I haven’t quite figured out yet what exactly the relevance of scoping even is in the context of Scheme. I’d have to look into this more deeply to actually understand how it’s used, I certainly wouldn’t mind any pointers as to where I could read some pre-existing explanations/introductions.[1]

The idea to connect the functionality for super let somehow more closely with macros is certainly one I could find compelling myself. I don’t believe an API exclusive to procedural macros would be the best approach, but that’s probably a minor discussion point & likely could be solved by also offering such functionality to declarative macros with some syntax based on RFC 3086 – Declarative macro metavariable expressions.

I find the mental model of literally “generating a binding in an outer scope” (hygienically), then working with a simple identifier an interesting one that I have never really considered before.

Realistically, I’d believe that gaining actual functionality for modifying AST outside the macro’s invocation isn’t going to happen all that easily, but one could certainly at least pretend, what if it was? How would you use it to solve the problems super let wants to solve? Is this a lot easier to use? And if it does seem like a conceptually or syntactically easier to work with approach, one might be able to get as close as possible for usability without needing any actual general mechanism to truly modify AST outside of your code.

Which probably means coming full-circle (you still need some language syntax that macros actually expand to) but maybe not re-arriving exactly at super let.


One complication with super let is that there’s so many (subtle) existing properties & mechanisms for expressions to determine the (drop) scope of temporary values. If a macro receives some expression, it might want to control where temporary variables within that expressions drop - it might want to define its own values that are supposed to act like temporaries, and define where those drop - it might even want to specify how the macro-call (as an expression) itself interacts with the rules of temporary lifetime extension. Only some of these use-cases can easily be re-interpreted as a simple assignment to an identifier, others not so easily.


If you haven’t seen those already, there are some more recent discussions / design documents around super let that you can find in this Zulip thread.


  1. I have used Racket/Scheme before; I’m not looking for a beginner tutorial or anything :wink: ↩︎

Thank you for the thoughtful reply!

I realize I should clarify my Racket analogy a bit. My core point isn't merely that "super let reminds me of macro lifting," but rather a structural observation: If Rust's macro system possessed a Racket-like lift primitive, super let wouldn't need to be a core language feature at all; it could simply be reinvented as macro-provided syntax.

1. The Racket Analogy Clarified

To see exactly how this relates to scoping, let’s look at what syntax-local-lift-expression actually does in Racket. The macro does not merely paste text outward. It asks the expander to evaluate an expression in an enclosing context and returns a fresh, hygienic identifier bound to it.

#lang racket
(require (for-syntax syntax/parse))

(define-syntax (compute-once stx)
  (syntax-parse stx[(_ expr)
     ;; The expander hoists `expr` to the module's top level,
     ;; and returns a hygienic identifier for the local code to use.
     (define lifted-id (syntax-local-lift-expression #'expr))
     lifted-id]))

(define (local-function)
  (displayln "Local function started.")
  (define val (compute-once (begin (displayln "Heavy computation!") 42)))
  (+ val val))

(local-function)
(local-function)

Output:

Heavy computation!      <-- Lifted! Evaluated exactly once at the top level.
Local function started.
84
Local function started.
84

2. Translating to Rust: Reinventing super let

Today, Rust procedural macros are strictly local token-replacers (TokenStream -> TokenStream). The hypothetical capability I have in mind would be context-sensitive, looking closer to (TokenStream, &mut Context) -> TokenStream.

If a macro could tell the compiler: "Create a fresh hygienic binding for this expression in the enclosing placement/drop scope, then give me back the identifier," then super_let could be implemented purely as a library macro:

#[proc_macro]
pub fn super_let(input: TokenStream, cx: &mut Context) -> TokenStream {
    let expr = parse_expr(input);

    // Ask the expansion context to place this expression in the
    // enclosing drop scope and return a hygienic identifier.
    let lifted_id = cx.lift_expr(expr);

    // Hand the output tokens back to the compiler using standard quotes.
    // The macro locally uses the hygienic handle provided by the compiler.
    quote! {
        {
            #lifted_id
        }
    }.into()
}

Then user code could write:

let writer = {
    println!("opening file...");
    let filename = "hello.txt";

    Writer::new(&super_let!(File::create(filename).unwrap()))
};

writer.something(); // no error

3. Addressing the "Temporary Lifetime Rules" Complication

You rightly pointed out that Rust has very subtle mechanisms to determine the drop scope of temporary values, and mapping macro expressions to these rules could be complicated.

However, I believe this complication actually highlights exactly why a lift primitive is conceptually so powerful: it eliminates the need to interact with temporary extension rules altogether.

My perspective is that a reference should always point to a well-defined "place", and temporaries should be no exception. The current temporary lifetime extension rules are notoriously complex precisely because temporaries are "ghost" values. The compiler uses rigid syntactic heuristics to guess when to extend their lifetimes, which is exactly why things break down inside macros or function calls.

By lifting an expression, the expander physically generates a canonical let binding (e.g., let __hygienic_id = expr;) in the targeted drop scope.

The temporary ceases to be a temporary—it is transformed into a standard, orthodox local variable (a canonical "place"). Because of this, all lifetime and drop semantics become strictly canonical. The macro doesn't need to specify or micromanage how a temporary interacts with the extension rules, because the temporary has simply become a named local variable. The Borrow Checker then evaluates it using the most basic, universally understood rules of Rust.

The Design-Space Question

I am absolutely not claiming this exists today, nor that it would be trivial to implement. Breaking the pure-function TokenStream -> TokenStream model is a massive architectural shift for rustc.

But if such an operation did exist, the conceptual role of super let changes. It becomes just one possible built-in spelling of a much more fundamental metaprogramming operation: "place this value in an enclosing drop scope, while exposing only a hygienic local handle to it."

That is the design-space question I am exploring: Should this capability fundamentally belong only to the core language syntax, or is it, in principle, a macro-system capability, assuming Rust ever gained a controlled, hygienic way for macros to interact with their surrounding context?

Thanks again for engaging with this idea!

At this point there is a question: should we add &mut Context to proc macros or some kind of primitive, not exactly super let, that the macro will expand to, informing the compiler to do the following transformation:

place this value in an enclosing drop scope, while exposing only a hygienic local handle to it.

There, for example, the handle for proc macro may be the id of the placed "instruction". Both are technically equivalent because the latter can be viewed as a functional style rewrite of the former, with all conditional super let, as well as other subtleties, handled.

I believe that for RFC there should be previous discussion about other use cases that will benefit from the context being passed. Otherwise, an argument that super let is a new core part of the language and its syntax is not as strong, because it can be made into a compiler built-in macro, just like format_args!.

Please note that super let doesn't only come up in macros, it can also be used in situations like this:

let mut foo;
let mut bar;
let mut baz;

let qux: &mut dyn Iter = if quux {
    foo = foo_lock.borrow_mut();
    bar = foo.bar();
    &mut bar
} else {
    baz = baz.iter();
    &mut baz
}

While this is more powerful than super let it begs the question of whether we want something this powerful, to the point that it allows breaking the syntactic order of execution of a program. For example in your Racket snippet:

  • the first statement that is executed is (local-function)
  • the first statement of local-function is (displayln "Local function started.")
  • I would thus assume that the first string printed is Local function started.
  • the program actually prints Heavy computation! before that

To understand how the program actually behaves you need to read all the code, even the one that syntactically comes after!

On the other hand Rust's super let is limited to allocating a local in the enclosing scope, which you could consider as a "pure" operation. The initializer of that local is still evaluated when the code flow reaches the super let. In other words super let foo = bar() is equivalen to super { let foo }; foo = bar();

Your proposed implementation of the super_let macro does not match this behaviour, since it evaluates the given expression before the current scope started, and even does so unconditionally (e.g. if condition { super_let!(foo()) } would still call foo() if condition is false, which is terrible!). You might not even be able to implement the correct behaviour with an API that exposes only lift_expr, since that really only allows you to implement super { let <lifted_id> = <expr> } and does not allow = <expr> to be omitted.

I also wonder why you need proc macros to be become unpure and expose a lift_expr method to them. A super { ... } syntactic construct would do the same in a much simplier way. This is not to say that I support adding it though, since as I said it seems "too powerful" to me.

2 Likes