Skip to content
rusthacks.dev
Generics

Extension Traits in Rust to Add Methods to Foreign Types

Rust forbids adding inherent methods to foreign types, but a trait you define can. Extension traits are how itertools, futures and tokio add methods to the standard library.

8 min read
Summarize in AI
Flat illustration for the post “Extension Traits in Rust to Add Methods to Foreign Types”, composed around traits.

You cannot add a method to str, Vec or Option: they live in another crate, and Rust does not let you reopen a type the way Ruby or C# does. What you can do is define a trait with the method you want and implement it for the foreign type. Import the trait and the method appears as if it had always been there. This is the extension trait pattern, and it is how itertools, futures::StreamExt, tokio::io::AsyncReadExt and anyhow::Context all work. This post builds one, covers the rules that make it legal, the naming and scoping conventions, the ambiguity trap and the pattern's limits. Everything compiled on Rust 1.99, edition 2024.

The shape of an extension trait

The trait is yours, so you can implement it for any type, including one from the standard library. The orphan rule forbids implementing a foreign trait for a foreign type, and this does neither of those things:

rust
trait StrExt {
    fn is_blank(&self) -> bool;
    fn truncate_chars(&self, max: usize) -> String;
}

impl StrExt for str {
    fn is_blank(&self) -> bool {
        self.trim().is_empty()
    }

    fn truncate_chars(&self, max: usize) -> String {
        if self.chars().count() <= max {
            return self.to_string();
        }
        let mut out: String = self.chars().take(max.saturating_sub(1)).collect();
        out.push('~');
        out
    }
}

fn main() {
    println!("{}", "   \t".is_blank());
    println!("{}", "hello".is_blank());

    let title = String::from("Extension traits in Rust, a long title");
    println!("{}", title.truncate_chars(16));
    println!("{}", "short".truncate_chars(16));
    println!("{}", "naïve café".truncate_chars(6));
}
Text
true
false
Extension trait~
short
naïve~

The impl is for str, not String or &str, and it still works on all three because of method resolution: auto-deref finds str's methods, including trait methods in scope. Implementing for the unsized type is the general advice, since one impl covers everything that derefs to it. The truncation counts characters, not bytes, and so a string with multi-byte characters is not cut mid-codepoint (cutting at a byte index would panic).

The trait must be in scope

The method exists only where the trait is imported. In the same module that defines it there is nothing to do; elsewhere it needs a use. Forgetting that gives a puzzling error, because the type clearly has the method in the file next door:

rust
mod text {
    pub trait StrExt {
        fn is_blank(&self) -> bool;
    }

    impl StrExt for str {
        fn is_blank(&self) -> bool {
            self.trim().is_empty()
        }
    }
}

fn main() {
    println!("{}", " ".is_blank());
}
Text
error[E0599]: no method named `is_blank` found for reference `&'static str` in the current scope
  --> src/main.rs:14:24
   |
14 |     println!("{}", " ".is_blank());
   |                        ^^^^^^^^ method not found in `&'static str`
   |
   = help: items from traits can only be used if the trait is in scope
help: trait `StrExt` which provides `is_blank` is implemented but not in scope; perhaps you want to import it
   |
 1 + use crate::text::StrExt;
   |

The compiler found the trait and told you to import it. Adding use text::StrExt; fixes it. In a library, convention puts extension traits in a module called prelude or ext, so users write one glob import. The error and its cousins are listed in E0599.

A blanket impl: extending every type that has a capability

The more powerful form implements the trait for all types meeting a bound. This is how a method like .pipe(f), .tap(f) or .context("...") is added to everything:

rust
use std::fmt::Display;

trait ResultExt<T, E> {
    fn context_msg(self, msg: &str) -> Result<T, String>;
}

impl<T, E: Display> ResultExt<T, E> for Result<T, E> {
    fn context_msg(self, msg: &str) -> Result<T, String> {
        self.map_err(|e| format!("{msg}: {e}"))
    }
}

trait Pipe: Sized {
    fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
        f(self)
    }
}

impl<T> Pipe for T {}

fn parse_port(s: &str) -> Result<u16, String> {
    s.parse::<u16>().context_msg("invalid port")
}

fn main() {
    println!("{:?}", parse_port("8080"));
    println!("{:?}", parse_port("99999"));

    let n = "  42 ".trim().pipe(|s| s.parse::<i32>().unwrap_or(0)).pipe(|n| n * 2);
    println!("{n}");
}
Text
Ok(8080)
Err("invalid port: number too large to fit in target type")
84

impl<T> Pipe for T {} implements the trait for literally every sized type, using the default method in the trait body. The trait has no required methods, so the impl block is empty. Blanket impls are powerful and also the usual source of trouble, as the next section shows. The same approach is how anyhow adds .context() to every Result whose error is an std::error::Error.

The ambiguity trap

If two traits in scope provide a method with the same name for the same type, the call is ambiguous and the compiler refuses to pick:

rust
trait Describe {
    fn name(&self) -> String;
}

trait Label {
    fn name(&self) -> String;
}

struct Item;

impl Describe for Item {
    fn name(&self) -> String {
        "item (describe)".into()
    }
}

impl Label for Item {
    fn name(&self) -> String {
        "item (label)".into()
    }
}

fn main() {
    let i = Item;
    println!("{}", Describe::name(&i));
    println!("{}", <Item as Label>::name(&i));
    println!("{}", i.name());
}
Text
error[E0034]: multiple applicable items in scope
  --> src/main.rs:27:22
   |
27 |     println!("{}", i.name());
   |                      ^^^^ multiple `name` found
   |
note: candidate #1 is defined in an impl of the trait `Describe` for the type `Item`
  --> src/main.rs:12:5
   |
12 |     fn name(&self) -> String {
   |     ^^^^^^^^^^^^^^^^^^^^^^^^
note: candidate #2 is defined in an impl of the trait `Label` for the type `Item`
  --> src/main.rs:18:5
   |
18 |     fn name(&self) -> String {
   |     ^^^^^^^^^^^^^^^^^^^^^^^^
help: disambiguate the method for candidate #1
   |
27 -     println!("{}", i.name());
27 +     println!("{}", Describe::name(&i));
   |
help: disambiguate the method for candidate #2
   |
27 -     println!("{}", i.name());
27 +     println!("{}", Label::name(&i));
   |

Two fully qualified forms work: Trait::method(&x) and <Type as Trait>::method(&x). The bare i.name() does not. This problem is why extension traits should have distinctive method names, and why library authors add a prefix or suffix when a name is likely to collide: if your extension adds .first() to iterators it will clash the day the standard library or itertools adds its own. Rust has a safeguard for the second case, an inherent method always wins over a trait method, so new standard-library methods can shadow yours silently. Clippy and the compiler lint unstable_name_collisions warn when your trait method name collides with an unstable standard method that may stabilise later.

Extending your own generic type with conditional methods

Extension traits also make a clean way to add methods only for elements that qualify, in the same spirit as the conditional impls in generic structs:

rust
trait SliceStats {
    fn mean(&self) -> Option<f64>;
    fn median(&self) -> Option<f64>;
}

impl SliceStats for [f64] {
    fn mean(&self) -> Option<f64> {
        if self.is_empty() {
            None
        } else {
            Some(self.iter().sum::<f64>() / self.len() as f64)
        }
    }

    fn median(&self) -> Option<f64> {
        if self.is_empty() {
            return None;
        }
        let mut v = self.to_vec();
        v.sort_by(f64::total_cmp);
        let mid = v.len() / 2;
        Some(if v.len() % 2 == 0 { (v[mid - 1] + v[mid]) / 2.0 } else { v[mid] })
    }
}

fn main() {
    let data = vec![4.0, 1.0, 9.0, 3.0];
    println!("{:?} {:?}", data.mean(), data.median());
    println!("{:?}", [7.5].median());
    println!("{:?}", Vec::<f64>::new().mean());
}
Text
Some(4.25) Some(3.5)
Some(7.5)
None

The impl is on [f64] specifically, so the methods exist for vectors and arrays of floats and for nothing else. The median sorts a copy with total_cmp, because sorting floats with partial_cmp is the trap described in sorting with Ord and PartialOrd.

Extending Iterator itself

The most common published extension traits add adapters to every iterator, with a blanket impl over Iterator. The implementation returns a small struct that is itself an iterator, so the extension composes with the rest of the chain:

rust
trait IterExt: Iterator + Sized {
    fn every_nth(self, n: usize) -> EveryNth<Self> {
        EveryNth { inner: self, n: n.max(1), pos: 0 }
    }
}

impl<I: Iterator> IterExt for I {}

struct EveryNth<I> {
    inner: I,
    n: usize,
    pos: usize,
}

impl<I: Iterator> Iterator for EveryNth<I> {
    type Item = I::Item;

    fn next(&mut self) -> Option<I::Item> {
        loop {
            let item = self.inner.next()?;
            self.pos += 1;
            if (self.pos - 1) % self.n == 0 {
                return Some(item);
            }
        }
    }
}

fn main() {
    let ids: Vec<u32> = (1..=10).every_nth(3).collect();
    println!("{ids:?}");

    let words = ["a", "b", "c", "d", "e"];
    let picked: Vec<_> = words.iter().every_nth(2).map(|w| w.to_uppercase()).collect();
    println!("{picked:?}");
}
Text
[1, 4, 7, 10]
["A", "C", "E"]

The supertrait bound Iterator + Sized gives the default method access to self by value, and the blanket impl means that every iterator in the program, including ones that did not exist when the trait was written, gains every_nth. The struct EveryNth is generic over the inner iterator, so the pipeline is still one concrete type with no allocation. This is exactly the shape of itertools::Itertools, which has about 100 such methods; reading its source is the best tutorial on the pattern. The iterator side of this is covered in implementing Iterator.

Why not a wrapper or a free function?

OptionCall siteCosts
Free functionis_blank(s)Does not chain; no method completion
Newtype wrapperText(s).is_blank()Wrap at every call; lose the inner type's methods unless Deref
Extension traits.is_blank()Needs use; can collide with future methods
Inherent method (your own type)s.is_blank()Only for types you own

The extension trait is the best fit when the new behaviour is a natural method that reads left to right in a chain, and the type is not yours. For behaviour that changes the meaning of a type, a newtype is better, since it carries an invariant, as in the newtype pattern.

Compatibility and when not to do this

Publishing an extension trait on std types makes your crate's behaviour depend on what std adds. A new inherent method with your name silently takes precedence, which changes what your callers run without a compile error. Keep the name specific, mark the trait #[doc(hidden)] only if it is internal, and think of adding a method to a public extension trait as a breaking change for anyone who implements the trait themselves, which is why you may want it sealed (see the sealed trait pattern). If a helper is used in one file, a private function is simpler and carries no future risk. Reach for the pattern when you find yourself writing the same free function in three modules and wishing it were a method.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.