How to Use Enum Variants as Function Pointers

You cannot use enum variants as function pointers; instead, store function pointers inside enum variants and match to call them.

The dispatch problem

You are building a calculator, a plugin system, or a simple command router. You want to store different behaviors in a single collection and call them later based on a tag. In Python, you would drop functions into a dictionary. In JavaScript, you would store callbacks in an object. Rust refuses to play along. You cannot hand an enum variant to the compiler and expect it to magically execute code. Enums are strictly data containers. They describe what something is, not what it does.

Rust separates data from behavior by design. This separation prevents accidental execution, keeps memory layouts predictable, and forces you to be explicit about when and how code runs. When you need an enum to trigger work, you must pack the executable code inside the variant yourself. The tool for that job is a function pointer.

Why enums do not execute code

Think of an enum like a labeled switchboard. The label says "Route A" or "Route B." The switchboard itself does not transmit data. It just holds the physical wires. To make communication happen, you need to connect those wires to an active line. In Rust, that active line is a function pointer.

A function pointer is a memory address pointing to executable instructions. It carries no state, captures no variables, and takes zero space on the stack. When you store a function pointer inside an enum variant, you turn a passive data tag into an active dispatch mechanism. The enum becomes a type-safe wrapper around a raw CPU jump target.

This design choice is intentional. Rust does not allow implicit coercion from enum variants to functions because it would break the language's core guarantee: data and control flow must be explicit. If the compiler silently turned Operation::Add into a callable function, you would lose visibility over argument types, return values, and side effects. Packing the pointer yourself forces you to declare the contract upfront.

Packing the pointer

The syntax looks like a regular enum, but each variant holds a function signature instead of a primitive or struct.

fn add(a: i32, b: i32) -> i32 { a + b }
fn sub(a: i32, b: i32) -> i32 { a - b }

/// Holds a reference to executable code for a binary operation.
enum Operation {
    Add(fn(i32, i32) -> i32),
    Sub(fn(i32, i32) -> i32),
}

fn main() {
    // Store the function pointer inside the variant.
    let op = Operation::Add(add);

    // Extract the pointer and call it through the enum.
    match op {
        Operation::Add(f) => println!("{}", f(2, 3)),
        Operation::Sub(f) => println!("{}", f(2, 3)),
    }
}

The fn(i32, i32) -> i32 syntax is the type signature for the pointer. It tells the compiler exactly what arguments the address expects and what it returns. When you write Operation::Add(add), Rust does not copy the function. Functions are zero-sized types. The compiler rewrites add into a raw memory address and stores that address inside the Add variant.

At runtime, the match statement checks which variant is active. It unpacks the pointer, loads it into a CPU register, and executes an indirect jump. The CPU follows the address to the actual add or sub code and runs it. This is the same mechanism behind virtual tables and function pointers in C. The enum just adds a type-safe wrapper around the raw address.

Convention aside: developers usually alias the signature with type BinOp = fn(i32, i32) -> i32; before defining the enum. This keeps the variant list readable and makes refactoring easier if you later change the argument count.

What happens under the hood

The compiler performs two distinct checks when you use this pattern. First, it verifies that the function you pass matches the pointer signature exactly. If you pass a function that takes u32 instead of i32, you get E0308 (mismatched types) at the enum construction site. Second, it verifies that every branch of your match returns the same type. If one branch returns i32 and another returns String, the compiler rejects the block.

Memory layout is straightforward. The enum occupies the size of a single pointer plus a discriminant byte. On most platforms, that is eight bytes for the pointer and one byte for the tag, padded to a sixteen-byte alignment. There is no heap allocation. There is no vtable lookup. The dispatch is a single conditional branch followed by an indirect call.

When you borrow the enum with &self, the pointer is copied out of the variant. Function pointers implement Copy, so the borrow does not move the data. You can call the operation repeatedly without consuming the enum. This makes the pattern ideal for configuration objects, command queues, and strategy patterns where the same behavior runs multiple times.

Trust the layout. Function pointers are the cheapest dispatch mechanism Rust offers. Use them when performance matters and the signature is fixed.

A realistic command router

Manual matching works for two variants. It becomes tedious when you have ten. The standard solution is to attach a method to the enum so callers do not repeat the dispatch logic.

fn multiply(a: i32, b: i32) -> i32 { a * b }

/// Routes binary math operations through a type-safe wrapper.
enum CalculatorOp {
    Add(fn(i32, i32) -> i32),
    Sub(fn(i32, i32) -> i32),
    Mul(fn(i32, i32) -> i32),
}

impl CalculatorOp {
    /// Executes the stored operation with the given arguments.
    fn execute(&self, a: i32, b: i32) -> i32 {
        match self {
            CalculatorOp::Add(f) => f(a, b),
            CalculatorOp::Sub(f) => f(a, b),
            CalculatorOp::Mul(f) => f(a, b),
        }
    }
}

fn main() {
    // Build a queue of operations without repeating match blocks.
    let ops = vec![
        CalculatorOp::Add(add),
        CalculatorOp::Mul(multiply),
    ];

    // Dispatch through the method instead of manual matching.
    for op in ops {
        println!("Result: {}", op.execute(4, 5));
    }
}

The execute method takes &self. It borrows the enum, unpacks the pointer, and calls it. Because fn pointers are Copy, the borrow does not move the pointer out of the enum. You can call execute as many times as you want. The compiler verifies that every branch returns an i32. If you forget a branch, you get a non-exhaustive pattern error. If you change the function signature, the compiler flags the mismatch at the enum definition, not at the call site.

This pattern scales cleanly. You can add a Result return type, inject error handling, or wrap the pointer in a Option for nullable operations. The enum remains the single source of truth for what operations exist. The method remains the single source of truth for how they run.

Keep the method thin. Dispatch logic should not contain business rules. If you find yourself adding conditionals inside execute, you are probably better off using a trait or a state machine.

Where the compiler draws the line

The biggest trap is confusing function pointers with closures. A closure captures environment variables. A function pointer cannot. If you try to shove a closure into an fn field, the compiler rejects it with E0308 (mismatched types). Closures have unique, anonymous types that implement the Fn, FnMut, or FnOnce traits. They do not coerce to fn unless they capture nothing. Even then, you must explicitly cast them with as fn(...) -> ....

Another common mistake is mixing return types across variants. If one variant holds fn(i32) -> i32 and another holds fn(i32) -> String, the enum becomes impossible to match cleanly. You would need a generic enum or a trait object. Stick to uniform signatures when using this pattern. The compiler will throw E0308 if you try to assign a mismatched pointer.

Trait bound errors also appear frequently. If you accidentally write Fn(i32, i32) -> i32 instead of fn(i32, i32) -> i32, the compiler expects a trait object, not a pointer. You will see E0277 (trait bound not satisfied) because bare traits cannot be stored directly in enums. You would need Box<dyn Fn(i32, i32) -> i32> to make it compile. That introduces heap allocation and vtable dispatch. Decide which tradeoff you want before you type the signature.

Convention aside: always use lowercase fn for pointers and uppercase Fn for trait objects. The visual difference prevents subtle type confusion during code review.

Choosing your dispatch strategy

Use fn pointers inside enums when you need zero-cost dispatch and all functions share the exact same signature. Use Box<dyn Fn> when your callbacks capture local variables or when you need to store heterogeneous closures in a collection. Use direct enum methods when the logic is simple enough to inline and you want to avoid indirection overhead. Use trait objects when you need to extend behavior across multiple modules without modifying the enum definition.

Pick the tool that matches your performance budget. Function pointers are fast and predictable. Closures are flexible but carry heap allocation and vtable costs. Know which one you need before you write the enum.

Where to go next