Telex logo Telex

Rust Patterns That Matter #16: Storing and Returning Closures

Post 16 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Previous: #15: Fn, FnMut, FnOnce | Next: #17: Builder Pattern

You want a struct with a callback field. What type do you give it? You want a function that returns a closure. What return type do you write? The answer touches one of Rust's most distinctive features: every closure has a unique, anonymous type that you can't name.

The motivation

struct Button {
    label: String,
    on_click: ???, // what type goes here?
}

You try fn(), but that only works for function pointers, not closures that capture variables. You try Fn():

error[E0277]: the size for values of type `dyn Fn()`
              cannot be known at compile time

dyn Fn() is a trait object - it has no fixed size. You can't put an unsized type directly in a struct field.

Why closure types are anonymous

Each closure the compiler generates is a unique struct. It captures the variables it needs and implements the appropriate Fn trait. The struct's name is generated by the compiler and never exposed to you.

let a = |x: i32| x + 1;
let b = |x: i32| x + 2;
// a and b have DIFFERENT types, even though both are Fn(i32) -> i32

This is intentional. Unique types let the compiler monomorphise: each closure's call is inlined directly, with no dynamic dispatch overhead. But it means you can't write a struct field like callback: the_type_of_my_closure because that type has no name.

Pattern 1: Box<dyn Fn()>

Heap-allocate the closure behind a trait object. The struct stores a fixed-size pointer:

struct Button {
    label: String,
    on_click: Box<dyn Fn()>,
}

impl Button {
    fn new(label: impl Into<String>, on_click: impl Fn() + 'static) -> Self {
        Button {
            label: label.into(),
            on_click: Box::new(on_click),
        }
    }

    fn click(&self) {
        (self.on_click)();
    }
}

fn main() {
    let btn = Button::new("Submit", || println!("clicked!"));
    btn.click();
}

Box<dyn Fn()> is the pragmatic default for stored closures. One heap allocation to store the closure, and a vtable pointer for dynamic dispatch. The overhead is negligible for event handlers and callbacks.

Pattern 2: Generics

If you want to avoid the heap allocation, make the struct generic over the closure type:

struct Button<F: Fn()> {
    label: String,
    on_click: F,
}

This is monomorphised: no heap allocation, no dynamic dispatch. The closure is stored inline. But the struct's type now includes the closure's anonymous type, which means:

Generics work well when a struct has exactly one callback and you don't need to store heterogeneous instances. For anything more flexible, use Box<dyn Fn>.

Returning closures

To return a closure from a function, use impl Fn in the return type:

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

let add_five = make_adder(5);
println!("{}", add_five(3)); // 8

impl Fn(i32) -> i32 means "I'm returning some concrete type that implements this trait, but I'm not telling you which one." The compiler knows the concrete type and monomorphises the caller. No heap allocation.

The limitation: all return paths must return the same concrete type. You can't conditionally return different closures:

// ERROR: `if` and `else` have incompatible types
fn make_op(add: bool) -> impl Fn(i32) -> i32 {
    if add {
        |x| x + 1
    } else {
        |x| x * 2 // different anonymous type!
    }
}

Boxing for conditional returns

When you need to return different closures from different branches, box them:

fn make_op(add: bool) -> Box<dyn Fn(i32) -> i32> {
    if add {
        Box::new(|x| x + 1)
    } else {
        Box::new(|x| x * 2)
    }
}

Both branches return Box<dyn Fn(i32) -> i32> - a single concrete type (the box) wrapping different trait objects. This compiles.

Telex's approach: Rc<dyn Fn()>

Telex stores callbacks as Rc<dyn Fn()> instead of Box<dyn Fn()>. The reason: a single callback might be cloned into multiple event handlers (a button's click handler registered with both a keyboard shortcut and a mouse target). Rc allows sharing the same closure without heap-allocating separate copies. See Designing a TUI Framework - Part 1 for details.

When to use which

See it in practice: Building a Chat Server #4: Commands and Plugins uses this pattern for the filter registry.