Any and Downcasting in Rust Trait Objects Explained
std::any::Any lets you ask a trait object what concrete type it holds. Learn downcast_ref, Box<dyn Any>, the as_any pattern for your own traits, and when an enum is the better design.

Rust erases types when you put a value behind dyn Trait: the caller can call the trait's methods and nothing else. std::any::Any is the escape hatch that brings the concrete type back, by letting you ask "is this actually a Circle?" at run time and get the Circle out if so. It is how error types are inspected through Box<dyn Error>, how plugin systems and ECS engines store heterogeneous values, and how a typed message bus works. It is also the feature most often reached for when a design needs a different shape. This post shows how downcasting works, the three forms (downcast_ref, downcast_mut, downcast), the 'static restriction, a type-keyed map, and the alternatives that usually serve better. Everything compiled on Rust 1.99, edition 2024.
What Any is
Any is a trait with a single method, type_id, implemented automatically for every type that is 'static (owns all its data and holds no short-lived references). Because it is implemented for everything that qualifies, any such value can be turned into a &dyn Any and later compared by its TypeId.
use std::any::{Any, TypeId};
fn describe(value: &dyn Any) -> String {
if let Some(n) = value.downcast_ref::<i32>() {
format!("i32 {n}")
} else if let Some(s) = value.downcast_ref::<String>() {
format!("String {s:?}")
} else if value.is::<f64>() {
"some f64".to_string()
} else {
"unknown type".to_string()
}
}
fn main() {
println!("{}", describe(&42_i32));
println!("{}", describe(&String::from("hi")));
println!("{}", describe(&2.5_f64));
println!("{}", describe(&'c'));
println!("{}", TypeId::of::<i32>() == TypeId::of::<i32>());
println!("{}", TypeId::of::<i32>() == TypeId::of::<u32>());
}i32 42
String "hi"
some f64
unknown type
true
falseThe check compares the TypeId stored in the vtable with the one for the requested type, and returns the data pointer cast to that type on a match. That is one pointer comparison plus a branch, so downcasting is cheap. What is not cheap is the design smell: every if let ... downcast_ref is a place where the compiler stops checking that you handled all the cases.
Downcasting a boxed value
The three methods differ by what you hold. downcast_ref and downcast_mut work on references. Box<dyn Any>::downcast::<T>() takes the box by value and returns a Result, where the Err gives the untouched box back so you can try another type:
use std::any::Any;
fn into_text(value: Box<dyn Any>) -> String {
let value = match value.downcast::<String>() {
Ok(s) => return *s,
Err(original) => original,
};
match value.downcast::<u32>() {
Ok(n) => n.to_string(),
Err(_) => "<unsupported>".to_string(),
}
}
fn main() {
println!("{}", into_text(Box::new(String::from("owned"))));
println!("{}", into_text(Box::new(77_u32)));
println!("{}", into_text(Box::new(1.5_f32)));
let mut slot: Box<dyn Any> = Box::new(10_i64);
if let Some(n) = slot.downcast_mut::<i64>() {
*n += 5;
}
println!("{:?}", slot.downcast_ref::<i64>());
}owned
77
<unsupported>
Some(15)This is also how JoinHandle::join() hands back a panic payload: Err(Box<dyn Any + Send>). To print what a thread panicked with, you downcast that box to &str and then String, because panic!("literal") carries a &'static str and panic!("{x}") carries a String.
The restriction: only 'static types
Any requires 'static. The reason is soundness: TypeId cannot distinguish &'a str from &'b str, since lifetimes are erased before runtime, so allowing borrowed data would let you turn a short-lived reference into a long-lived one by a downcast. The compiler simply refuses:
use std::any::Any;
fn main() {
let local = String::from("borrowed");
let r: &str = &local;
let boxed: Box<dyn Any> = Box::new(r);
println!("{}", boxed.is::<&str>());
}error[E0597]: `local` does not live long enough
--> src/main.rs:5:19
|
4 | let local = String::from("borrowed");
| ----- binding `local` declared here
5 | let r: &str = &local;
| ^^^^^^ borrowed value does not live long enough
6 | let boxed: Box<dyn Any> = Box::new(r);
| ----------- coercion requires that `local` is borrowed for `'static`
7 | println!("{}", boxed.is::<&str>());
8 | }
| - `local` dropped here while still borrowed
|
= note: due to object lifetime defaults, `Box<dyn Any>` actually means `Box<(dyn Any + 'static)>`The workaround is to own the data (clone into a String) or to restructure so the borrowed form never goes through Any. Note that &'static str is fine, which is why string literals work in panic payloads.
Downcasting from your own trait
The real use case is a trait object where you sometimes need the concrete type. You cannot call downcast_ref on a &dyn Shape directly, because dyn Shape is not dyn Any (up-casting between trait objects needs help; trait upcasting is now stable for supertraits, which changes this). The classic workaround is an as_any method:
use std::any::Any;
trait Shape {
fn area(&self) -> f64;
fn as_any(&self) -> &dyn Any;
}
struct Circle {
r: f64,
}
struct Rect {
w: f64,
h: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
3.14159 * self.r * self.r
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
for s in &shapes {
match s.as_any().downcast_ref::<Rect>() {
Some(rect) => println!("rect {}x{} area {}", rect.w, rect.h, s.area()),
None => println!("not a rect, area {:.2}", s.area()),
}
}
}not a rect, area 3.14
rect 2x3 area 6Each implementor writes the same one-line as_any. Put trait Shape: Any as a supertrait and, on current stable Rust, upcast the trait object directly: let any: &dyn Any = shape; works without the helper when Any is a supertrait. That removes the boilerplate.
Here is that supertrait form compiled. It is shorter, and the call site does the conversion:
use std::any::Any;
trait Shape: Any {
fn area(&self) -> f64;
}
struct Rect {
w: f64,
h: f64,
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
}
fn main() {
let shape: Box<dyn Shape> = Box::new(Rect { w: 2.0, h: 4.0 });
let any: &dyn Any = &*shape;
match any.downcast_ref::<Rect>() {
Some(r) => println!("rect width {} area {}", r.w, shape.area()),
None => println!("something else"),
}
}rect width 2 area 8The conversion &*shape to &dyn Any is a trait upcast, stable since Rust 1.86. On an older compiler the same line is an error and you need the as_any method.
Reading a panic payload
The one place nearly every program meets Any is a thread that panicked. join returns the payload as a Box<dyn Any + Send>, and the message is inside, as either a &'static str or a String depending on how the panic was written:
use std::any::Any;
use std::thread;
fn message(payload: &(dyn Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"non-string panic payload".to_string()
}
}
fn main() {
std::panic::set_hook(Box::new(|_| {}));
let a = thread::spawn(|| panic!("fixed text")).join().unwrap_err();
let n = 7;
let b = thread::spawn(move || panic!("computed {n}")).join().unwrap_err();
let c = thread::spawn(|| std::panic::panic_any(404_u16)).join().unwrap_err();
println!("{}", message(&*a));
println!("{}", message(&*b));
println!("{}", message(&*c));
}fixed text
computed 7
non-string panic payloadServers use this to turn a panic in a worker into a log line instead of a lost thread. The panic hook is silenced above only to keep this demonstration's output short; in a real service leave the default hook on so the backtrace is printed.
A type-keyed map
The pattern that justifies Any is a container keyed by type: a request-extensions bag, a dependency registry, an ECS resource store. Axum's Extension and http::Extensions work this way.
use std::any::{Any, TypeId};
use std::collections::HashMap;
#[derive(Default)]
struct Extensions {
map: HashMap<TypeId, Box<dyn Any>>,
}
impl Extensions {
fn insert<T: Any>(&mut self, value: T) -> Option<T> {
self.map
.insert(TypeId::of::<T>(), Box::new(value))
.and_then(|old| old.downcast().ok().map(|b| *b))
}
fn get<T: Any>(&self) -> Option<&T> {
self.map.get(&TypeId::of::<T>())?.downcast_ref()
}
}
struct RequestId(u64);
struct UserName(String);
fn main() {
let mut ext = Extensions::default();
ext.insert(RequestId(9001));
ext.insert(UserName("ada".into()));
let replaced = ext.insert(RequestId(9002));
println!("{:?}", ext.get::<RequestId>().map(|r| r.0));
println!("{:?}", ext.get::<UserName>().map(|u| u.0.as_str()));
println!("{}", ext.get::<u8>().is_none());
println!("{}", replaced.is_some());
}Some(9002)
Some("ada")
true
trueThe type system keeps the map honest. The key for RequestId is always paired with a RequestId, so the downcast inside get cannot fail on well-formed use. The newtype wrappers matter here: two u64 values would collide under the same key, which is why each piece of data gets its own struct.
What to use instead when you can
| If you want to | Prefer | Why |
|---|---|---|
| Handle a fixed set of shapes | An enum | The compiler checks every case with match, see enum dispatch |
| Call different behaviour per type | A trait method | No runtime type check, new types plug in |
| Inspect an error's cause | source() chain, or a typed error enum | Downcasting Box<dyn Error> works but couples callers to internals |
| Open-ended plugins or type-keyed data | Any with a map | The legitimate use |
A rule of thumb: if you can list the types you expect to downcast to, you want an enum. If the list is open (anything a plugin might register), you want Any. The cost of choosing Any early is that adding a new case no longer produces a compile error where it needs handling.
Treat the table as a review checklist. In code review, an Any downcast with two or three hard-coded types is the signal to ask whether those types are a closed set. Nine times out of ten they are, and the enum is a smaller, faster, safer program that also documents itself.
Performance, and what Any is not
A downcast is a vtable load, a 128-bit comparison and a branch, a few nanoseconds. Boxing the value costs an allocation, which is the part that matters in a hot path. Any is not reflection: it cannot list fields, call methods by name or discover traits. TypeId values are opaque and unstable across compiler versions, so do not persist them or send them across a network. Use a stable name or numeric tag in a wire format. And std::any::type_name returns a string for diagnostics only, with no guarantee of format or uniqueness.


