How to Return Different Types from a Function (Trait Objects vs Enums)

Use enums for known, finite return types and trait objects for dynamic dispatch over shared behaviors.

The return type dilemma

You are writing a function that returns a shape. Sometimes it returns a circle. Sometimes it returns a square. You write the signature fn get_shape() -> ???. Rust stops you. It demands a single concrete type. You cannot return "Circle or Square" without telling the compiler how to represent that choice.

This happens constantly. A parser returns tokens that are keywords, identifiers, or numbers. A UI framework returns events that are clicks, drags, or key presses. A plugin system returns handlers that come from different crates. You have multiple types, but the function signature allows only one.

Rust gives you two tools. You can bundle the types into an enum, or you can hide them behind a dyn Trait object. The choice is not just syntax. It changes how your code is compiled, how it runs, and how it evolves.

The enum: a sealed set of options

An enum in Rust is a tagged union. It groups a fixed set of variants under one name. The compiler knows every possible variant at compile time. It reserves enough space for the largest variant and adds a small tag to track which variant is active.

Think of an enum like a multi-tool. The tool has a screwdriver, a knife, and a saw. You know exactly what tools are available. You just don't know which one is extended at any given moment. The set is closed. No one can add a wrench to your multi-tool without modifying the tool itself.

/// Represents a geometric shape with known variants.
enum Shape {
    /// A circle defined by its radius.
    Circle { radius: f64 },
    /// A square defined by its side length.
    Square { side: f64 },
}

/// Returns a shape. The compiler knows the return type is exactly Shape.
fn get_shape() -> Shape {
    // We return one variant. The enum wraps it.
    Shape::Circle { radius: 5.0 }
}

The enum approach is fast. The value lives inline. There is no heap allocation. There is no pointer indirection. When you call get_shape(), the result is a struct containing a tag byte and the data. The size is predictable.

You consume an enum with a match. The compiler checks exhaustiveness. If you add a new variant like Triangle, the compiler rejects every match that doesn't handle it. This is a feature. It forces you to update all call sites. You cannot accidentally ignore a new case.

The trait object: dynamic dispatch

A trait object erases the concrete type. It keeps only the behavior defined by the trait. You write Box<dyn Draw>. The dyn keyword marks the trait as dynamic. The Box provides a heap allocation because the size of the concrete type is unknown.

Think of a trait object like a universal remote. You don't know what device is behind the wall. You only know it responds to "Power" and "Volume". You press the button, and the signal goes through. The remote doesn't care if it's a TV, a stereo, or a projector. It just sends the command.

/// Defines behavior for drawing a shape.
trait Draw {
    fn draw(&self);
}

/// A concrete circle type.
struct Circle {
    radius: f64,
}

impl Draw for Circle {
    fn draw(&self) {
        println!("Drawing circle with radius {}", self.radius);
    }
}

/// A concrete square type.
struct Square {
    side: f64,
}

impl Draw for Square {
    fn draw(&self) {
        println!("Drawing square with side {}", self.side);
    }
}

/// Returns a shape behind a trait object.
/// The caller sees only the Draw interface.
fn get_shape() -> Box<dyn Draw> {
    // The concrete type is Circle, but the return type is Box<dyn Draw>.
    Box::new(Circle { radius: 5.0 })
}

The trait object approach is flexible. You can return any type that implements Draw. The caller doesn't need to know about Circle or Square. It just calls .draw().

Under the hood, Box<dyn Draw> contains a pointer to the data and a pointer to a vtable. The vtable is a static table of function pointers generated by the compiler. When you call .draw(), the runtime looks up the function in the vtable and jumps to it. This is dynamic dispatch. It costs a pointer indirection and prevents inlining. The compiler cannot optimize the call as aggressively as it can with a concrete type.

Exhaustiveness versus extensibility

The choice between enum and trait object is a trade-off between exhaustiveness and extensibility.

Enums are exhaustive. The compiler guarantees you handled every case. This makes enums safer for logic that depends on covering all possibilities. If you add a new variant, the compiler breaks your build until you update the match. This is painful during development but prevents bugs in production. You cannot forget to handle a new token type or a new event.

Trait objects are extensible. You can add a new type that implements the trait without touching the calling code. If a third-party crate adds a Hexagon that implements Draw, your get_shape() function can return it, and the caller just works. The caller doesn't recompile. This is the Open/Closed principle. You can extend behavior without modifying existing code.

The trade-off is real. Enums give you compile-time safety at the cost of flexibility. Trait objects give you runtime flexibility at the cost of compile-time checks. If you add a new method to the trait, every implementation must update, but callers that don't use the new method don't break. With an enum, adding a variant breaks callers immediately.

Pick the tool that matches your design. If the set of types is fixed and you want the compiler to police your logic, use an enum. If the set of types is open and you want to allow extensions, use a trait object.

Realistic example: a plugin system

Consider a text editor that supports syntax highlighting plugins. Each plugin provides a highlighter. The editor loads plugins at runtime. You cannot list all possible plugins in an enum because they come from external crates.

/// Trait for syntax highlighting plugins.
trait Highlighter {
    fn highlight(&self, code: &str) -> String;
}

/// A plugin for Rust code.
struct RustHighlighter;

impl Highlighter for RustHighlighter {
    fn highlight(&self, code: &str) -> String {
        format!("[Rust] {}", code)
    }
}

/// A plugin for Python code.
struct PythonHighlighter;

impl Highlighter for PythonHighlighter {
    fn highlight(&self, code: &str) -> String {
        format!("[Python] {}", code)
    }
}

/// Factory function that returns a highlighter based on language.
/// Returns a trait object because the concrete type varies.
fn load_highlighter(lang: &str) -> Box<dyn Highlighter> {
    match lang {
        "rust" => Box::new(RustHighlighter),
        "python" => Box::new(PythonHighlighter),
        _ => Box::new(RustHighlighter), // Fallback
    }
}

/// Editor uses the highlighter without knowing the type.
fn run_editor(lang: &str) {
    let highlighter = load_highlighter(lang);
    // Dynamic dispatch. The editor doesn't care about the concrete type.
    let result = highlighter.highlight("fn main() {}");
    println!("{}", result);
}

The editor code is decoupled from the plugins. You can add a JavaScriptHighlighter in a separate crate, implement Highlighter, and return it from load_highlighter. The editor doesn't change. This is why trait objects exist. They enable plugin architectures, dependency injection, and runtime polymorphism.

If you tried to use an enum here, you would need to modify the enum every time a new plugin is added. The editor would have to recompile. The enum approach fails when the set of types is not owned by the library.

Pitfalls and compiler errors

Trait objects introduce constraints that enums do not. The compiler enforces these strictly.

If you try to return dyn Draw without a pointer, the compiler rejects it. Trait objects are unsized. The compiler doesn't know how much memory to allocate. You must use Box, &, Rc, or Arc.

fn bad_return() -> dyn Draw {
    // Error: the trait Draw is not dyn compatible
    // Error: dyn Draw has no size
    Circle { radius: 1.0 }
}

The error message tells you that dyn Draw is unsized. You need a smart pointer or reference. Fix it by returning Box<dyn Draw>.

Another pitfall is object safety. Not every trait can be used as a trait object. The trait must be object-safe. This means it cannot have methods that return Self, use generic parameters, or require Sized. If you try to make an object-unsafe trait into a dyn, the compiler emits E0038.

trait Cloneable {
    fn clone_me(&self) -> Self; // Returns Self. Not object-safe.
}

fn get_cloneable() -> Box<dyn Cloneable> {
    // Error: the trait Cloneable is not dyn compatible
    // E0038: the trait Cloneable cannot be made into an object
    Box::new(42)
}

The compiler cannot build a vtable for clone_me because the return type varies by implementation. The vtable needs a fixed function signature. If you need to clone behind a trait object, use Clone explicitly or return Box<dyn Cloneable> where Cloneable is defined with fn clone_me(&self) -> Box<dyn Cloneable>.

Enums avoid these issues. They are always sized. They are always exhaustive. They don't have object safety rules. The complexity is in the match, not the type system.

Performance implications

Enums are faster. The compiler knows the layout. It can inline methods. It can optimize the match into a jump table or even eliminate branches. There is no heap allocation. There is no vtable lookup.

Trait objects are slower. The Box allocates on the heap. The call goes through a vtable pointer. The compiler cannot inline the method because it doesn't know the concrete type at compile time. In tight loops, this overhead adds up.

If performance is critical and the set of types is known, use an enum. If the overhead is negligible and flexibility matters, use a trait object. Profile before optimizing. The allocation cost of Box is often dwarfed by I/O or network latency. Don't sacrifice architecture for micro-optimizations unless the profiler tells you to.

Decision matrix

Use an enum when the set of types is closed and known at compile time. Use an enum when you need the compiler to verify that you handled every case. Use an enum when performance matters and you want to avoid heap allocation. Use an enum when the types are defined in the same crate and you control the variants.

Use a trait object when third-party code provides the types. Use a trait object when you need to add new types without recompiling the calling code. Use a trait object when the set of types is open and cannot be listed in advance. Use a trait object when you are building a plugin system or dependency injection framework.

Reach for Box<dyn Trait> when you need ownership of the trait object. Reach for &dyn Trait when you only need to borrow the behavior and want to avoid allocation. Reach for Rc<dyn Trait> or Arc<dyn Trait> when multiple owners need to share the trait object.

Convention and style

The community prefers enums by default. Enums are explicit. They make the data flow visible. They enable pattern matching. If you can use an enum, you probably should. Trait objects are the escape hatch for extensibility. Use them when you have a reason.

When you do use trait objects, keep the trait small. Fewer methods mean a smaller vtable and less coupling. If the trait grows large, consider splitting it or switching to an enum.

Write Box<dyn Trait> with the space after dyn. The convention is dyn Trait, not dynTrait. The dyn keyword is part of the type syntax. It signals dynamic dispatch. Always include it. The old syntax Box<Trait> is deprecated and confusing.

Treat the trait object boundary as a module boundary. Code inside the boundary knows the concrete types. Code outside sees only the trait. This separation keeps your architecture clean.

Where to go next