What Are Dynamically Sized Types (DSTs) in Rust?

Dynamically Sized Types (DSTs) are types like slices and trait objects with sizes unknown at compile time, requiring access via pointers and the ?Sized bound.

The slice that broke your stack

You write a helper function to log a collection. It works perfectly for Vec<String>. Then you try to pass a slice &[String] extracted from a larger buffer. The compiler screams. You didn't change the logic. You just changed the input type. The error points to a trait bound you never heard of: Sized.

You're staring at a slice, and Rust is telling you it doesn't know how big it is. This is your first encounter with Dynamically Sized Types, or DSTs. They are the reason you can't store a str directly, why trait objects need the dyn keyword, and why generic functions sometimes refuse to compile.

What a DST actually is

A Dynamically Sized Type is a type whose size cannot be determined at compile time. The compiler needs to know the size of every value to allocate stack space. If the size depends on runtime data, the compiler hits a wall.

Common DSTs include:

  • str: The raw string slice. It represents UTF-8 text, but the length varies.
  • [T]: The raw slice. It represents a sequence of T, but the count varies.
  • dyn Trait: A trait object. It represents any type implementing Trait, but different implementations can have different sizes.

You cannot store a DST directly on the stack. You cannot declare let s: str = "hello";. The compiler has no idea how many bytes to reserve. You also cannot store a DST inside a struct field directly. Structs must have a known size so the compiler can calculate the offset of each field.

DSTs are not "dynamic" in the sense that they change size over time. A str has a fixed length once created. The "dynamic" part refers to the compile-time perspective. The size is a runtime value, not a compile-time constant. The type describes a shape, but leaves the dimensions blank.

The fat pointer saves the day

Since you can't hold a DST directly, you must access it through a pointer. This pointer is special. It carries extra metadata so the program knows how to handle the data. We call this a fat pointer.

A fat pointer is usually two machine words wide. One word holds the memory address. The other word holds metadata. For &str, the metadata is the length. For &[T], it's the length. For &dyn Trait, it's a pointer to a virtual table.

The pointer itself has a known size. The compiler knows exactly how much stack space to allocate for the pointer. The DST lives behind the pointer, usually on the heap or inside a larger allocation. The fat pointer bridges the gap between the compiler's need for static size and the runtime's variable data.

// str is a DST. You cannot use it as a function parameter directly.
// fn bad(text: str) { ... } // Error: size for values of type str cannot be known

// &str is a fat pointer. It contains a pointer to the data and the length.
// The size of &str is known at compile time (two words).
fn good(text: &str) {
    // text.len() reads the length from the fat pointer metadata.
    println!("Length: {}", text.len());
}

fn main() {
    let s = String::from("Hello, DSTs!");
    // &s coerces to &str. The fat pointer captures the address and length.
    good(&s);
}

The &str type is the workhorse of Rust string handling. It lets you slice strings without copying. When you write &s[0..5], you get a new &str fat pointer pointing to the substring with a new length. No allocation happens. The original String remains untouched.

Trait objects and dynamic dispatch

Trait objects are DSTs that enable polymorphism. When you write dyn Trait, you are creating a type that can hold any implementation of Trait. Since implementations can have different sizes, dyn Trait must be a DST.

Trait objects use dynamic dispatch. The compiler generates a virtual table for the trait. This table contains pointers to the concrete methods for a specific type. The fat pointer for &dyn Trait stores a pointer to the data and a pointer to the vtable.

When you call a method on a trait object, the code looks up the function pointer in the vtable and jumps to it. This prevents the compiler from inlining the call. There is a small runtime cost. The trade-off is flexibility. You can store heterogeneous collections and call methods without knowing the concrete type at compile time.

/// A trait for rendering UI components.
trait Render {
    fn render(&self);
}

struct Button {
    label: String,
}

impl Render for Button {
    fn render(&self) {
        println!("Rendering button: {}", self.label);
    }
}

struct Label {
    text: String,
}

impl Render for Label {
    fn render(&self) {
        println!("Rendering label: {}", self.text);
    }
}

/// Accepts any type that implements Render.
/// dyn Render is a DST, so we use a reference.
fn render_widget(widget: &dyn Render) {
    // Dynamic dispatch happens here.
    // The compiler looks up the render method in the vtable.
    widget.render();
}

fn main() {
    let btn = Button { label: "Click".to_string() };
    let lbl = Label { text: "Status".to_string() };

    // Both calls work because both types implement Render.
    render_widget(&btn);
    render_widget(&lbl);
}

The dyn keyword is mandatory in modern Rust. You cannot write &Render. The dyn makes it explicit that you are dealing with a trait object and dynamic dispatch. This clarity prevents accidental creation of trait objects where you intended static dispatch.

Convention aside: Prefer &dyn Trait in function arguments when you don't need ownership. It allows callers to pass owned values, borrowed values, or Boxes without forcing allocation. If you need ownership, use Box<dyn Trait>. The Box owns the heap allocation and carries the fat pointer.

Pitfalls and compiler errors

DSTs introduce specific failure modes. The compiler protects you, but the errors can be confusing if you don't know what to look for.

The most common error is E0277 (the trait Sized is not implemented for T). This happens when you try to use a DST where a sized type is required. Generic parameters default to T: Sized. If you write fn foo<T: Render>(t: &T), the compiler assumes T has a known size. Passing &dyn Render fails because dyn Render is not Sized.

trait Render {
    fn render(&self);
}

// This function requires T to be Sized.
// dyn Render is not Sized, so this call will fail.
fn static_render<T: Render>(t: &T) {
    t.render();
}

// To accept DSTs, add the ?Sized bound.
// ?Sized means "Sized is optional".
fn flexible_render<T: Render + ?Sized>(t: &T) {
    t.render();
}

fn main() {
    // static_render(&dyn Render); // Error E0277: the trait Sized is not implemented for dyn Render
    flexible_render(&dyn Render); // Compiles
}

Another pitfall is trying to create a Vec of DSTs. You cannot write Vec<dyn Trait>. A Vec stores elements contiguously in memory. It needs to know the stride (size of each element) to calculate offsets. If elements are DSTs, the stride is unknown.

The compiler rejects Vec<dyn Trait> with E0277. The solution is Vec<Box<dyn Trait>>. The Box has a known size (a pointer). The Box points to the DST on the heap. The vector stores the boxes, not the trait objects directly.

trait Widget {
    fn draw(&self);
}

struct Button;
impl Widget for Button { fn draw(&self) { println!("Button"); } }

struct Text;
impl Widget for Text { fn draw(&self) { println!("Text"); } }

fn main() {
    // This fails. Vec needs a known element size.
    // let widgets: Vec<dyn Widget> = vec![]; // Error E0277

    // This works. Box<dyn Widget> has a known size.
    let widgets: Vec<Box<dyn Widget>> = vec![
        Box::new(Button),
        Box::new(Text),
    ];

    for w in &widgets {
        w.draw();
    }
}

Be careful with size_of. You cannot call size_of::<str>(). The compiler will error because the size is unknown. You can call size_of::<&str>(), which returns the size of the fat pointer. This is usually two words (16 bytes on 64-bit systems).

When to use what

DSTs and their wrappers appear everywhere in Rust. Choosing the right form depends on ownership, mutability, and performance needs.

Use &str when you need to read a string without taking ownership. It works with String, string literals, and slices. It is the standard input type for string functions.

Use String when you need to own and modify text. It wraps a Vec<u8> and guarantees valid UTF-8. Convert to &str when passing to functions.

Use &[T] when you need to read a sequence without ownership. It works with Vec<T>, arrays, and slices. It is the standard input type for collection functions.

Use Vec<T> when you need to own and modify a sequence. It manages heap allocation and growth. Convert to &[T] when passing to functions.

Use &dyn Trait when you need to call methods on a trait object without ownership. It enables dynamic dispatch with zero allocation. Prefer this in function arguments.

Use Box<dyn Trait> when you need to own a trait object. It allocates the concrete type on the heap and stores the fat pointer. Use this for heterogeneous collections or when the trait object must outlive the current scope.

Use Rc<dyn Trait> when you need shared ownership of a trait object in single-threaded code. It uses reference counting to manage lifetime.

Use Arc<dyn Trait> when you need shared ownership of a trait object across threads. It uses atomic reference counting for thread safety.

Use ?Sized when writing a generic function that must accept DSTs. This is rare in application code. It is common in library code where a trait wants to be object-safe and generic-friendly.

Use dyn explicitly for all trait objects. The keyword is required and improves readability. Never rely on implicit trait object creation.

Where to go next

DSTs are the foundation of Rust's flexibility. They let you trade compile-time size for runtime adaptability. Once you understand fat pointers and the Sized bound, the compiler errors become clear signals rather than roadblocks.