Telex logo Telex

Rust Patterns That Matter #14: Enum Dispatch vs Trait Objects

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

Previous: #13: 'static + Clone | Next: #15: Fn, FnMut, FnOnce

You have different types that share common behaviour. Classic polymorphism. In most languages, you'd reach for an interface or base class. Rust gives you two tools: enums and trait objects. They solve the same problem differently, and choosing wrong leads to friction as the code evolves.

Approach 1: Enum dispatch

enum Shape {
    Circle { radius: f64 },
    Rect { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { radius } =>
                std::f64::consts::PI * radius * radius,
            Shape::Rect { width, height } =>
                width * height,
            Shape::Triangle { base, height } =>
                0.5 * base * height,
        }
    }

    fn perimeter(&self) -> f64 {
        match self {
            Shape::Circle { radius } =>
                2.0 * std::f64::consts::PI * radius,
            Shape::Rect { width, height } =>
                2.0 * (width + height),
            Shape::Triangle { base, height } => {
                let side = (base * base / 4.0 + height * height).sqrt();
                base + 2.0 * side
            }
        }
    }
}

Adding a new operation (like perimeter) is easy: write a new method with a match. All variants are handled in one place.

Adding a new variant (like Pentagon) requires changing every match arm in every method. The compiler helps - exhaustive matching means you get errors for every method you haven't updated. But the changes are scattered.

Approach 2: Trait objects

trait Shape {
    fn area(&self) -> f64;
    fn perimeter(&self) -> f64;
}

struct Circle { radius: f64 }
struct Rect { width: f64, height: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    fn perimeter(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radius
    }
}

impl Shape for Rect {
    fn area(&self) -> f64 { self.width * self.height }
    fn perimeter(&self) -> f64 { 2.0 * (self.width + self.height) }
}

// Usage with trait objects:
let shapes: Vec<Box<dyn Shape>> = vec![
    Box::new(Circle { radius: 5.0 }),
    Box::new(Rect { width: 3.0, height: 4.0 }),
];

Adding a new variant (a new struct implementing Shape) is easy: write the struct and its impl. No existing code changes.

Adding a new operation (a new method on the trait) is hard: every existing implementation must be updated. If the trait is in a library, adding a method is a breaking change.

The expression problem

This is a well-known tradeoff in language design:

Neither is universally better. The question is: how will your code evolve? Will you add new types or new operations?

Performance

Enum dispatch is static. The compiler sees the match and can inline each arm. No heap allocation, no vtable indirection. This matters in hot loops.

Trait objects use dynamic dispatch: a vtable pointer for method calls, and typically a Box for heap allocation. The indirection prevents inlining. For event handlers or plugin systems called infrequently, this is irrelevant. For per-pixel rendering or per-packet processing, it can matter.

Telex's choice

Telex uses an enum for its View type:

enum View {
    Text(String),
    Container { children: Vec<View> },
    Button { label: String, on_click: Rc<dyn Fn()> },
    // ... other built-in widgets
}

The set of built-in widget types is known and controlled by the framework. New widgets are added by the Telex developers, not by users. This is a closed set. New operations (rendering, diffing, layout) are added more often than new widget types. Enum dispatch is the natural fit. See Designing a TUI Framework - Part 1 for the rationale.

When to use which

The decisive question: who defines the concrete types? If it's you, use an enum. If it's your users, use a trait.

See it in practice: Building a Chat Server #4: Commands and Plugins uses this pattern for the /join, /nick, /kick command system.