MoreRSS

site iconCorrcodeModify

This is an ongoing series of articles about idiomatic Rust and best practices.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of Corrcode

Thinking in Traits

2026-11-02 08:00:00

Rust favors composition over inheritance. I’m sure you’ve heard that before, but rarely does anyone take the time to explain what it means in practice. Also, why is this model the right choice for Rust?

Let’s take a step back and look at an example: have you ever asked yourself why collect() works the way it does?

let doubled: Vec<_> = numbers.iter().map(|x| x * 2).collect();

How does collect know to produce a Vec here? To answer that, let’s first look at the signature of Iterator::collect:

fn collect<B: FromIterator<Self::Item>>(self) -> B
where
    Self: Sized,
{
    FromIterator::from_iter(self)
}

Note how it doesn’t mention Vec at all.

In fact, it doesn’t “know” about any collection type or any other type, for that matter. The thing it does know is that whatever type you want must be capable of constructing itself from an iterator. And Vec<_> happens to be a type that does. Further note that we didn’t specify the element type of the vector. The compiler is clever enough to infer that from the context!

The mechanism is the same for any other supported type, like hash maps, strings, or any custom collection type of your choosing as long as it implements FromIterator for the iterator’s item type.

And what “collecting” means is also defined by the type itself. A String, for example, has the ability to collect characters:

let chars = ['a', 'b', 'c'];
let string: String = chars.iter().collect(); // "abc"

And a HashMap can collect key-value pairs:

let pairs = [("a", 1), ("b", 2), ("c", 3)];

// {"a": 1, "b": 2, "c": 3}
let map: HashMap<_, _> = pairs.iter().cloned().collect();

It’s only fair to mention that interfaces can also express shared contracts. Here, two independently defined implementations connect through a shared item type. This is “composition through contracts”:

Ranges, slice iterators, and custom iterators connect through collect() to Vec, HashSet, or a custom collection. Both traits share the item type T.
Any of these iterators can feed into any of these collections as long as the bounds are satisfied. For example, HashSet<T> also requires T: Eq + Hash.

Here, a producer yields values of some type T through the Iterator trait. On the other side, a collection implements FromIterator<T> to describe how it can build itself from those values. The shared item type T connects the two contracts, and collect() is the bridge between them. It requires only that the destination can be constructed from the items the iterator yields.

In our first example, the Vec<_> annotation selects the destination type. Because Vec<T> implements FromIterator<T>, the compiler knows how to connect that destination to our iterator. collect() is the glue that passes the iterator to that implementation, which creates and fills the vector.

Note how neither side needs to know the concrete type on the other side. The iterator doesn’t need to know how a vector stores its elements. And the vector doesn’t need to know where the items came from, only how to iterate over them.

That means we can extend either side independently. A new iterator can feed existing collections, and a new collection implementing FromIterator<T> can accept existing iterators yielding T. We don’t need a separate conversion for every producer–collection pair. All we need is one implementation, and collect() is the glue that brings it all together.

In simple Rust pseudocode, we can expand the calls step by step:

// We call:
iterator.collect::<Vec<T>>()

// collect() delegates to:
Vec::<T>::from_iter(iterator)

// Which conceptually expands to:
let mut vector = Vec::new();
while let Some(item) = iterator.next() {
    vector.push(item);
}
vector

The value of an abstraction is the code that no longer needs to exist between its users.

Boulevard of Broken Hierarchies

Class inheritance models “is-a” relationships. In Java, for example, an SSLSocket is a subclass of Socket. An encrypted socket is still a socket, after all. But does every socket operation make sense for it?

Socket.shutdownOutput() lets a TCP connection stop sending while continuing to receive, a feature called “half-closing.” Code using a socket might therefore do this:

void finishSending(Socket socket) throws IOException {
    socket.shutdownOutput();
}

On Android API levels 21 through 23, the platform’s SSLSocket implementations rejected this operation with UnsupportedOperationException. This caused a real crash in OkHttp 5.2.0. Modern OpenJDK supports TLS-aware shutdown, so this is a historical mismatch, not a universal limitation of TLS. Still, the Socket type exposed an operation without telling callers whether that implementation supported it.

With composition, a TLS connection can hold a socket internally without exposing every operation on it. It can provide reading and writing, with a separate capability for half-closing where supported. We can reuse the transport without inheriting its entire interface.

Simply replacing inheritance with interfaces—or traits—doesn’t fix a poorly chosen contract. What’s important is that the contract matches the capabilities of its implementations.

Capability, Not Taxonomy

Rust makes trait-based composition central to code reuse. In Rust, we ask what a type can do, not what it is. In other words, traits describe capabilities, not taxonomies.

Here are simplified versions of two central I/O traits from the standard library:

trait Read {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
}
trait Write {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize>;
}

Note how these are two independent traits. A type can implement Read, or Write, or both, or neither. You’re declaring which operations a type supports, without inheriting a bunch of unrelated methods. The capabilities are orthogonal.

For example:

  • A byte slice (&[u8]) implements Read but not Write.
  • The Sink returned by std::io::sink() implements Write but not Read.
  • A TcpStream implements both.

The trait implementation belongs to the type, not to a concrete value. Take std::fs::File, for example. It implements both Read and Write, but a file opened in read-only mode will still fail at runtime if you try to write to it. Even a file opened for writing can fail when the disk fills up. The trait implementation is a promise that the type supports those operations, but what it can’t do is guarantee that every operation will succeed at runtime.

If you want to distinguish read-only and writable handles at compile time, you could define a ReadOnlyFile and a WritableFile and only implement the appropriate traits for each. That rules out unsupported operations in your API, but it still doesn’t make I/O infallible.

Same Type, Different Capabilities

Rust can express that a container has a capability only when its contents do. For example, Vec<T> implements Clone when T implements Clone. Thanks to this trick, we don’t end up with two different Vec types, one cloneable and one not.

Here’s the same pattern for a small wrapper you could define yourself:

struct Batch<T> {
    items: Vec<T>,
}

impl<T: Clone> Clone for Batch<T> {
    fn clone(&self) -> Self {
        Self {
            items: self.items.clone(),
        }
    }
}

A Batch<String> is cloneable because String is cloneable. A Batch<std::net::TcpStream> is not, since TcpStream does not implement Clone. If you were to call .clone() on that batch, the code wouldn’t compile. You can store non-cloneable items in a batch; you just can’t clone that batch.

For this wrapper, you can use #[derive(Clone)] to generate the conditional implementation automatically. Note that the bound belongs on the implementation, not on Batch itself. Making your type cloneable requires T: Clone, but apart from that, Batch<T> is still a perfectly valid type for non-cloneable elements. The compiler works out which operations are available from the capabilities of the component types.

A conditional implementation can also apply to every type that satisfies a trait bound as opposed to one particular wrapper. This is called a blanket implementation. For example, the standard library implements ToString for every type that implements Display:

impl<T: std::fmt::Display + ?Sized> ToString for T {
    // Implementation omitted.
}

All you have to do to get a .to_string() method is implement Display for your type. The compiler automatically generates the ToString implementation for you.

The rule applies even to types written long after the standard library itself. This is an elegant way to express a reusable implication between capabilities: “If a type can be displayed, it can be turned into a string.”

The same idea appears in conversions. You might know that implementing From<T> for U automatically provides Into<U> for T through a blanket implementation. That’s why you normally implement From rather than writing the corresponding Into implementation yourself.

Building Blocks From Building Blocks

Conditional implementations also let us add behavior by wrapping one value in another. This is a different type of composition: “composition of values.” A wrapper holds another value and builds on its capabilities. Take our Read trait from earlier:

  • A File implements Read.
  • Wrapping it in a BufReader adds buffering, and the result still implements Read.
  • Calling .take(limit) on that reader limits how many bytes it yields, and the result still implements Read.
File
  → BufReader<File>
    → Take<BufReader<File>>

So a parser accepting R: Read doesn’t need separate support for files, buffered files, and length-limited buffered files. It just reads bytes!

We can add buffering and a byte limit without teaching the parser about either. The parser doesn’t need to understand how its input was assembled.

This works because each wrapper preserves the contract that the next piece needs. A BufReader<R> implements Read when R does, and so does Take<R>. The result of one composition becomes a building block for the next.

You can see the same idea in iterator chains.

  • .map() produces a Map<I, F> iterator that applies a function to each item of the underlying iterator I.
  • .filter() produces a Filter<I, P> iterator that yields only the items of I that satisfy the predicate P.
  • .take() produces a Take<I> iterator that yields at most the first n items of I.

Each type implements Iterator, so you can chain them.

Writing your own iterator connects your type to this vast ecosystem of adapters. All you need is to provide the associated Item type and the next() method. map, filter, fold, and collect already have default implementations, available when their additional bounds are satisfied. This is how you reuse code without a base class. Default methods provide behavior within a trait you implement; blanket implementations can make another trait apply automatically.

But adapters don’t have to be limited to a single, rigid contract either. They can provide rich, additional capabilities, too.

For example, Map<I, F> implements DoubleEndedIterator when its input iterator does, and it does so with an appropriate trait bound on the mapping function:

let values: Vec<_> = (0..5)
    .map(|n| n * 2)
    .rev()
    .collect();

assert_eq!(values, [8, 6, 4, 2, 0]);

So because the map adapter has a conditional implementation for DoubleEndedIterator, it preserves the ability to iterate backward through the input. We’re not limited to basic iteration.

And since we know the concrete iterator types, the compiler can optimize and inline the entire chain of adapters into a single loop. Optimized iterator code can perform comparably to a hand-written loop. So fully embracing iterator patterns has no runtime cost.

To Enum Or To Trait?

Hold on, Rust has another way to express “different kinds of things that are related”: enums! These are useful in situations where you know the full list of possibilities in advance.

enum InputSource {
    File { path: PathBuf },
    Http { url: String, timeout: Duration },
    Stdin,
}

Enums are great if you almost always want to handle all of the variants in one place. Traits are the better model if different types implement the same behavior and you don’t know all of the types right now. In other words, enums are a closed set of variants while traits are an open set of implementations.

You can’t add new variants to an enum without changing the enum definition. But traits enable the open/closed principle: you can add new implementations without modifying existing code. This allows other crates to extend your library without needing to modify your code.

Imagine your library defines the Record and FetchError types, and you want to provide a way for users to fetch records from different sources.

pub trait DataSource {
    fn fetch(&self) -> Result<Vec<Record>, FetchError>;
}

In your library, you implement DataSource for a PostgresSource and a RedisSource.

But now, users can implement it for their own CsvFileSource or a CustomApiSource. Heck, you don’t even need to know these types exist. And yet, your contract still holds: any type that implements DataSource can be used to fetch records.

The reverse is also true: an external contributor can define an extension trait and implement it for your type without you knowing about it. You can even do that with types from the standard library:

trait ScreamingCase {
    fn screaming_case(&self) -> String;
}

impl ScreamingCase for String {
    fn screaming_case(&self) -> String {
        self.to_uppercase()
    }
}

As long as the trait is in scope, you can call the method on a String:

let text = String::from("hello");
assert_eq!(text.screaming_case(), "HELLO");

No need to modify String or derive a ScreamingString subclass. This implementation is allowed because we define ScreamingCase in our crate. Rust’s coherence rules restrict implementations involving foreign traits and types. For example, we couldn’t implement the standard library’s Display trait directly for Vec<String>, since neither the trait nor the type is ours.

This is composition at the level of behavior: we combine an existing type with an independently defined capability. The type and the trait don’t need to know about each other. The same type can implement other, unrelated traits, and each function can ask for just the capabilities it needs by means of trait bounds:

// This works for any type that implements `ScreamingCase`.
// E.g. why not implement it for `Vec<String>`, or `char` as well?
fn scream<T: ScreamingCase>(value: &T) {
    println!("{}", value.screaming_case());
}

This open-set/closed-set principle is a good way to think about when to use enums and when to use traits. But to be fair, the lines can blur a bit. An enum can also implement traits, and a library can restrict who implements a trait through a technique called a “sealed trait.”

In application code, exhaustive enum checking can make refactoring easier because the compiler will tell you which match statements need to be updated when you add a new variant. Introduce a trait instead when you have a good reason to share a contract across types.

Smaller Traits, Better Contracts

The more unrelated responsibilities a trait combines, the harder it becomes to implement and reuse. Every implementer has to account for operations it may not need.

Consider this application context:

trait Context {
    fn fetch(&self, key: &str) -> Result<Record, Error>;
    fn store(&mut self, key: &str, record: &Record) -> Result<(), Error>;
    fn run(&mut self) -> Result<(), Error>;
}

Ask yourself: what concept does this trait capture? What contract does it enforce? It combines reading records, writing records, and running the application.

Perhaps your application really needs one type to provide it all, but a function that copies records shouldn’t require its inputs to run an application. Let’s split the capabilities:

trait Fetch {
    fn fetch(&self, key: &str) -> Result<Record, Error>;
}

trait Store {
    fn store(&mut self, key: &str, record: &Record) -> Result<(), Error>;
}

Now a read-only source can implement Fetch, a write-only destination can implement Store, and the execution runtime is left out. Your functions don’t need to know about the runtime either, and you can compose the exact capabilities you need:

fn copy_record<S: Fetch, D: Store>(
    source: &S,
    destination: &mut D,
    key: &str,
) -> Result<(), Error> {
    let record = source.fetch(key)?;
    destination.store(key, &record)
}

Each of your types can implement either trait. Or both.

This also explains how independent crates work with your types. Serde defines Serialize and Deserialize; your types implement them, often through derive macros. Serde’s serializers don’t need to know about your application’s structs in advance. They just use the shared contract: a type that implements Serialize can be serialized. Code becomes more composable as a consequence. If Serde decided to couple serialization and deserialization into a single trait, some use cases would be impossible to express.

All of that is great for testing, too. We can test copy_record with a fake Fetch implementation and a fake Store implementation, without creating a runtime.

The goal isn’t to make every trait contain exactly one method. Iterator has many methods, but they are all strictly related to the task of iterating over a sequence of values, and all but one (next()) have a default implementation. It’s fine to keep methods together when they describe the same contract, but if you’re unsure if callers need the independent capabilities, split your traits.

Rules For Designing Traits

Below are a couple of concrete tips around trait design.

  • When you approach a problem, ask what your code needs a type to be able to do. Often, an existing trait already expresses that. Check the standard library and popular crates first. And sometimes a concrete type or an enum is enough.

  • Write the code concretely first. If you find yourself copying similar implementations across different types, look for a shared contract. By then, you’ll have concrete examples to test your abstraction against. And when you do need a new trait, give it a contract you can explain in a single sentence.

  • Abstraction has a cost for readers. They may need to follow bounds and implementations across several files to understand a call. Sometimes that indirection hides details they shouldn’t need to care about. Sometimes it hides an important detail they should.

  • Trait-based abstractions in Rust often incur zero additional runtime overhead, but there is a compilation cost as well. The costs depend on how you use the trait. Generic code is usually monomorphized: the compiler generates code for the concrete types that use it. That permits static dispatch and inlining, but many instantiations can still increase compile time and binary size. For dyn-compatible traits, dynamic dispatch through dyn Trait lets the same caller operate on different implementations, but introduces indirection and can limit inlining. Not every trait supports this: FromIterator, for example, is not dyn-compatible.

Keep these trade-offs in mind when you introduce a trait. There is no free lunch.

Think Beyond the Parts

Let’s come back to our collect() once more. So far, we’ve used the destination type to choose a collection. But it can also determine control flow. For example, we can decide to stop iterating on the first error or keep going and collect every result, including errors.

I first learned about this from Karol Kuczmarski’s Iteration patterns for Result & Option, still one of my favorite Rust posts.

Here it is in action:

let input = ["10", "oops", "30"];

let every_result: Vec<Result<u32, _>> =
    input.iter().map(|s| s.parse()).collect();

let all_or_error: Result<Vec<u32>, _> =
    input.iter().map(|s| s.parse()).collect();

Note that in both cases, the iterator expressions are identical. The only thing that changes is the destination type:

  • Vec<Result<u32, _>> collects every parse result.
  • Result<Vec<u32>, _> returns the first error and stops advancing the iterator immediately.

In the second case, "30" is never parsed. If every parse succeeds, the second version returns Ok containing the vector of values. This behavior comes from the standard library’s FromIterator implementation for Result. Here’s a stub of that implementation:

impl<A, E, V> FromIterator<Result<A, E>> for Result<V, E>
where
    V: FromIterator<A>,
{
    // Implementation omitted.
}

What this says is:

An iterator of Result<A, E> values can be collected into a Result<V, E>, as long as V can collect values of type A.

The V: FromIterator<A> bound means that V could be a Vec<A> or any other type that can collect those values. collect() itself doesn’t need to know any of this: it just calls the destination type’s FromIterator implementation.

See how all of those pieces fit together? That’s the real payoff when you think in traits. A single implementation can connect a type to an entire ecosystem of algorithms, adapters, and collections.

A few carefully crafted capabilities can combine in ways their authors never imagined. That makes the whole greater than the sum of its parts. And that’s just great API design.

Local Reasoning in Rust

2026-10-02 08:00:00

A good metric for ergonomic API design is how much of a program you have to keep in your head at once to know what’s going on. It’s empowering if you can understand a function by its type signature and get immediate feedback on whether you used it correctly. Other times, it feels like you’re a code archaeologist: Was this value validated before? Is it safe to retry this call? Can this panic? Bad APIs make local reasoning hard.

“Local reasoning” here means being able to understand a piece of code from a limited amount of surrounding context and the contracts of the APIs it uses. The main point is that you can rely on those contracts without additional knowledge of the implementation.

What makes Rust feel different from other languages is the ability to encode invariants in the type system and do so at zero cost. That’s a rare combination.

Once you notice this “local reasoning principle”, you’ll see it everywhere in Rust’s standard library: through the use of Result and Option, borrows marked with &, enums to represent a closed set of possibilities, or explicit unsafe functions. This information is always visible in every function signature.

A simple way to apply this mindset yourself is to check if your function signatures communicate as much information as possible to the caller. Maybe show the signature to a friend or colleague and ask them to explain what it does. It can be eye-opening.

Let’s look at a few examples.

Put Relationships in the Signature

Consider this function signature:

fn first_line(text: &str) -> Option<&str>

Before even looking at the implementation, we already know that the function takes a borrowed input and may have no output. (Or, rather, it may return None.) Actually, if you know Rust’s lifetime elision rules, you know that the real signature is:

fn first_line<'text>(text: &'text str) -> Option<&'text str>

The input is tied to the output. That implies that the caller can’t keep using the returned string after the borrow of text has ended. And by extension, the function can’t return a temporary string either. The function is not making any long-lived allocations.

That’s a lot of useful information for just one function header!

All of that is great, but equally important, there are a few “implicit” assumptions that the signature does not guarantee. Take the function name: it suggests that the function returns “the first line of something.” However, the type system does not enforce that. It could return the last line for all we know, and the signature would be identical.

The function header also won’t tell us whether anything is logged, how it performs, or whether it panics.

Rust is often described as an explicit language. Yet it also uses type inference, lifetime elision, automatic borrowing of method receivers, and implicit coercions. That’s because writing everything out would make many programs harder to read. What you should make explicit depends on your context.

A good rule of thumb when designing an API is to look for relationships that callers would otherwise have to keep in their heads. Does your function really take any &str, or does it expect a string that has been validated in some way? For example, is an empty string valid input? What does None mean in the return value? Should it be treated as an error, or is it a valid case? Should it be a Result instead? Good function signatures tell a story about the relationships between inputs and outputs.

Keep Consequential Choices Visible

Consider these two calls, where text is a String:

inspect(&text);
consume(text);

The first call borrows text while the second one moves it.

Now let’s look at the context of the borrowed call:

fn inspect(text: &str) {
    println!("{text}");
}

let text = String::from("hello");
inspect(&text);

The compiler applies a deref coercion automatically. I think that’s a good compromise: the ownership decision is still visible, but the compiler handles the bookkeeping.

Some people argue that we could go one step further. Why not automatically borrow an owned argument in an ordinary function call? We could then write inspect(text) instead of inspect(&text), which seems convenient. But, as always, there’s a cost to convenience. Saving the & would remove information readers currently get from the expression itself: that the value does not move.[1]

Convenience does not always mean better ergonomics. This gives us a way to judge our own conveniences, too. Implementing Deref for a wrapper makes its target’s methods available implicitly. That’s convenient. But it’s a slippery slope. It can lead to leaky abstractions, where a wrapper type is treated as if it were the underlying type, but is not quite the same.

Suppose a UserId stores a String. You might consider implementing Deref<Target = str> for it, so that callers can use it as if it were a &str. Now you expose the whole string interface through Deref. You implicitly allow callers to treat the identifier as text, sidestepping all type invariants. Instead, an explicit as_str() leaves a visible point where the identifier is treated as text. It also lets your UserId newtype have an API of its own. Interaction with user IDs becomes more deliberate.

Use Deref only when the wrapper transparently behaves like its target and dereferencing is cheap and unsurprising. The standard library agrees.

Give Names a Purpose

Take a look at this function signature:

fn visit<F: FnMut(&str)>(callback: F)

Here, we introduce a name, F, give it a bound of FnMut(&str), and then use it exactly once for callback. I think that signature would be clearer if we put the requirement right next to where it’s used:

fn visit(callback: impl FnMut(&str))

Now you can read the signature from left to right. This saves you from jumping back and forth just to figure out what F means. One less thing to keep in your head while reading.[2]

Now, I’m not saying that version two is always better. For example, it can be helpful to keep generics separate from the rest of the signature when the generic type is used in multiple places:

fn choose<T>(first: T, second: T, take_first: bool) -> T {
    if take_first { first } else { second }
}

Here, T tells us that both arguments as well as the return value have the same type. The same thinking applies to return types:

fn nonempty(lines: &[String]) -> impl Iterator<Item = &str> {
    lines.iter().map(String::as_str).filter(|s| !s.is_empty())
}

impl Iterator<Item = &str> tells the caller what they can do with the result, namely iterate over borrowed strings. (Besides, if you tried writing out the concrete return type of that function, it would be unnecessarily long and complicated.)

Ask yourself: does naming this type help the caller understand something?

Ownership Beyond Memory

We usually learn about ownership in the context of memory. But the same rules apply in other situations.

Take file descriptors, for example. On Unix, a raw file descriptor is just an integer. That integer doesn’t tell you whether the descriptor is still open, or who’s responsible for closing it. Worse, once it’s closed, the operating system can reuse the number for something else.

Consider these two signatures, using types from std::os::fd:

fn inspect(fd: RawFd) -> std::io::Result<()>
fn inspect(fd: BorrowedFd<'_>) -> std::io::Result<()>

The first signature only gives us an integer. RawFd is literally just an alias for c_int. But that descriptor might already be closed. Those “time-of-check to time-of-use” bugs are a common pitfall of safe Rust.

The second guarantees that the descriptor remains open for the duration of the borrow.[3] The caller keeps its owner alive, and Rust checks that relationship when we borrow from a File:

use std::fs::File;
use std::os::fd::AsFd;

let file = File::open("notes.txt")?;
inspect(file.as_fd())?;

Now the compiler can help! You can’t drop file and then keep using the descriptor borrowed from it in safe Rust. You no longer need to search through the code to check whether someone closed it earlier.

But what if our function should really take ownership of the file descriptor? Use OwnedFd instead. When the OwnedFd is dropped, the descriptor is closed automatically, so the caller doesn’t have to remember to do it.

In a sense, memory and file descriptors share a similar set of types with different guarantees:

Memory File descriptors Use Case
Box<T> OwnedFd “I want to own this resource and close it when I’m done.”
&T BorrowedFd<'a> “I want to borrow this resource for a limited time.”
Raw pointer RawFd “I want to use this resource, but I don’t know who owns it or how long it will live.”

Lock guards are another example.

{
    let _guard = lock.lock();
    // do cool things with lock
} 
// We no longer have access to the lock here, because `_guard` was dropped. 

If you can access the data, you hold the lock. You don’t have to “trace the program back” to an earlier lock() call and check every path for an unlock. In C, that’s very much the case and easy to get wrong.

Of course, these types only guarantee what they encode. A BorrowedFd keeps track of one borrow, but it doesn’t guarantee exclusivity over the underlying resource. That means another process might still be writing to the same file.

But in general, you can stop relying on callers to remember the provenance of a resource. That’s a much stronger guarantee than simply saying “keep this open until you’re done” in your API documentation.

Raw handles are still necessary at a low-level boundary, but you don’t have to pass them through your entire application.

The lesson is that you can encapsulate ownership information in your own types and provide a safe wrapper around an unsafe API.

Explain Who Is Responsible

Sometimes things are truly outside of Rust’s control. In that case, the safety responsibility shifts to the user. We use unsafe APIs to make that inversion of responsibility explicit.

In Rust, there are two different responsibilities, which share the same keyword:

  • An unsafe fn says: “Before you call me, you MUST establish these conditions. This is your responsibility.”
  • An unsafe block means you’re responsible for satisfying the conditions of the unsafe operations inside the block.[4]

The difference is that an unsafe fn sets safety conditions that its caller must meet, while an unsafe block marks where the person writing the code claims that each unsafe operation’s safety conditions have been met.

In both cases, it is good practice to add safety comments to make readers aware of these conditions. Here’s how that could look in practice:

/// # Safety
/// `index` must be less than `values.len()`.
unsafe fn element_unchecked(values: &[u8], index: usize) -> u8 {
    // SAFETY: The caller guarantees that `index` is in bounds.
    unsafe { *values.get_unchecked(index) }
}

In general, you should use get(index) instead to handle the None case, but this example illustrates how to document safety obligations.

Since the type system can’t check the safety conditions, it’s your obligation to keep the documentation up to date. Suppose someone adds another unsafe operation to this function later. Does knowing that index is in bounds make that operation safe, too? Maybe. You have to check and potentially update your docs.

Quick tip: when you write a safety comment, explain why the operation is safe. “This is safe” doesn’t help the next person, but “The caller guarantees that the index is in bounds” gives them something they can check.

Another tip: in Rust 2024, unsafe operations inside unsafe functions warn by default unless you put them in an explicit unsafe block. You can enforce that with #![deny(unsafe_op_in_unsafe_fn)].

Don’t Make Callers Do Your Work

You’ve probably heard the advice to panic for programmer errors and return Result for recoverable failures. That’s reasonable, but who decides what counts as a programmer error?

You do, when you design the API.[5]

Consider the difference between indexing and get:

let item = items[index];
let item = items.get(index);

If you use indexing, you need to know that the index is valid to avoid a panic. With get, you can try the lookup and handle None if it fails. Remember that both are safe Rust: an invalid index doesn’t cause undefined behavior in either case.

Now suppose the index comes from user input. Is an out-of-bounds index really a bug in your program? Or is it something you should expect and handle?

One escape hatch is to make every caller check the index before calling your function. A strict precondition might make your work simpler, but think about your users. Before you document another thing the caller “must” do, ask whether your API could handle it instead. For example, you could return an Option and let callers decide what to do next.

That doesn’t mean you should avoid indexing altogether. If an index is indeed valid by construction, indexing can express that assumption directly. A panic then means there’s a bug in your API. It’s probably fine to panic in that case, instead of introducing undefined behavior.

And often it’s possible to sidestep these issues entirely. For example, if you need to visit each element of a collection, use an iterator. This way, you don’t have to worry about indices at all.

Decide What You Want to Promise

Another mental model for building great APIs is to think about what you want to guarantee to your users. Remember Hyrum’s Law:

With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.

With that in mind, think about what happens when you try to change your API.

For example, users can match on every variant of your public enum. They can’t forget a case, because the compiler will warn them about it. That’s local reasoning at work, which is great. But on the flip side, it also means that you can’t add a variant without breaking their code. You’ve broken a guarantee they depended on.

To prevent that, mark your enum as #[non_exhaustive] so you can add variants later:

#[non_exhaustive]
pub enum ServiceError {
    Unavailable,
    Rejected,
}

In that case, users have to include a fallback when matching the enum.[6] You’ve pushed the responsibility to the call site, which is likely the better place to decide what to do with an unexpected variant. Exhaustive matching keeps working inside your own crate.

Should you add #[non_exhaustive] to every public enum just in case? No. If the set of possibilities really is closed, exhaustive matching gives users a useful guarantee. Don’t take it away without a reason. Examples of closed sets include days of the week, months of the year, or the suits of a deck of cards:

pub enum Suit {
    Hearts,
    Diamonds,
    Clubs,
    Spades,
}

If you make this enum non-exhaustive, users can’t use your crate to implement a card game without having to handle an impossible case.

The awkward middle ground is a set that looks closed but really isn’t. HTTP status codes are a good example: mapping all the standard codes doesn’t mean you’re safe from a vendor inventing their own codes. This caused a real problem in http-types: a user reported that constructing a response with Cloudflare’s custom status codes panicked because the library’s StatusCode enum couldn’t represent them.

#[non_exhaustive] doesn’t solve that problem by itself. It lets you add additional variants in the future, but it doesn’t allow users to represent unknown codes today. How about we add Unregistered(u16)?

#[non_exhaustive]
pub enum Status {
    Ok,
    NotFound,
    // Other known status codes we can't name yet...
    Unregistered(u16),
}

Now users can handle codes which don’t have a name yet:

fn is_early_hints(status: Status) -> bool {
    match status {
        Status::Unregistered(103) => true,
        _ => false,
    }
}

But that introduces another compatibility trap. Suppose a later release adds an EarlyHints variant and starts returning it for code 103. The function now returns false for the same HTTP status code. The code still compiles, but its behavior has changed. I recommend reading “Pattern Matching and Backwards Compatibility” by Sean McArthur, the author of the http crate, about how even an enum with a catch-all variant can make promises you didn’t intend.

The escape hatch for the http crate was to make StatusCode an opaque struct with associated constants for the known codes like StatusCode::OK. Users can then construct values from numeric codes, even when the library doesn’t have a name for them:

use http::StatusCode;

let status = StatusCode::from_u16(599).unwrap();
assert_eq!(status.as_u16(), 599);

They can still match on familiar codes, but they have to include a fallback. And they can handle an unnamed code by inspecting its numeric value:

use http::StatusCode;

fn describe(status: StatusCode) -> &'static str {
    match status {
        StatusCode::OK => "success",
        code if code.as_u16() == 103 => "early hints",
        _ => "some other status",
    }
}

That’s pretty clever, because adding a new constant like StatusCode::EARLY_HINTS assigns a name to a value without changing the underlying representation, and numeric checks continue to work.

So before making something public, ask yourself: am I willing to uphold this guarantee forever? This applies to your entire public API, including enums and public fields inside structs.

Changing things later can break user code. From their perspective, it was part of the API all along.

Try It on Your Own APIs

I suggest you put that advice into practice. Pick a function in your codebase and look at it from the caller’s perspective. What do you have to know to use it correctly? Can you get that information from the signature, or do you have to read the implementation first?

Look for instructions in the documentation that the compiler could help enforce. Instead of writing “keep this resource alive”, maybe you can return a borrowed type. “Only pass validated text” might become a newtype with some validation in the constructor.

You don’t have to follow every single suggestion in this article, either. The goal is to make your API easier to understand and harder to misuse.

Where to go from here

Want a second pair of eyes on your Rust APIs? I can help you put these patterns to work in your codebase.

  • For your team. Training, code review, and architecture support to ship Rust with confidence.
  • For yourself. 1-on-1 mentorship for Rust design, architecture, and code review on real projects.
  1. RFC 241: Deref coercions discusses why automatically borrowing arguments would make local reasoning harder. ↩

  2. RFC 1951: Expand impl Trait explains ergonomics in terms of how much you have to keep in your head, and discusses who chooses the concrete type. ↩

  3. RFC 3128: I/O safety explains the analogy between raw handles and raw pointers, and introduces owned and borrowed handle types. ↩

  4. RFC 2585: Unsafe blocks in unsafe functions separates defining safety obligations from satisfying them. The Rust 2024 edition guide covers the lint’s current default. ↩

  5. RFC 236: Error conventions recommends expressing contracts through types where possible, and using Result or Option when a stricter contract is hard to justify. The discussion of task failure predates Rust 1.0; the advice about API contracts is the relevant part here. ↩

  6. RFC 2008: Non-exhaustive enums and structs discusses exhaustive matching and the freedom to add new variants. ↩

Rust Conferences 2027

2026-10-01 08:00:00

Planning your Rust conference trips for 2027? Here are the conferences with confirmed dates so far, followed by announced Rust tracks and workshops. Some workshop dates are still to be announced. We’ll add more conferences, ticket prices, and call for proposals (CFP) deadlines as organizers announce them.

Come say hi if you see us at any of these events! (We’ll bring Rust in Production stickers.) For the 2026 events, see Rust Conferences 2026.

Q1 2027

Rust Nation UK (London, UK)

Rust Nation UK

Rust Nation returns to London with a one-day, three-track conference at a new venue in the City of London. A separate workshop day is being planned for February 17, but is not yet confirmed.

  • When: February 18, 2027
  • Where: 1 Basinghall Avenue, London, UK
  • Format: 1 day of talks across 3 tracks
  • Focus: Rust developers of all experience levels
  • Tickets: Early-bird tickets are on sale; workshop tickets will be announced separately.
  • CFP: Announced as open; no closing date confirmed yet.
  • Links: Website | Tickets

Q2 2027

TokioConf (Portland, USA)

TokioConf

After its first edition in 2026, TokioConf returns to Portland. The talks focus on practical Rust: building and shipping software, architecture, testing, performance, and working with Tokio.

  • When: April 26-27, 2027
  • Where: The Armory, Portland, Oregon, USA
  • Focus: Practical Rust and Tokio
  • CFP: Open — closes November 30, 2026 at 11:59 PM Pacific.
  • Links: Website | CFP Announcement

RustWeek (Utrecht, Netherlands)

RustWeek

RustWeek brings the Rust community together in Utrecht for talks, workshops, a hackathon, and social activities. The week also includes the invitation-only Rust Project All Hands.

  • When: May 24-29, 2027
  • Where: Utrecht, Netherlands (talks at Kinepolis; workshops at Beatrixgebouw)
  • Format: 1 day of workshops, 2 days of talks, a hackathon, and social activities
  • Focus: Broad, open to Rust developers of all levels
  • Pricing: Individual tickets are €199, with an additional €100 discount available for those who can’t afford the full price (organizer announcement).
  • CFP: Open — closes January 10, 2027 at 23:59 CET. Workshop proposals are handled separately via email.
  • Links: Website | CFP Announcement

Q3 2027

RustConf (Vancouver, Canada + Online)

RustConf

Hosted by the Rust Foundation, RustConf heads to Vancouver in 2027, with online participation available too. More details about the program are still to come.

  • When: September 7-10, 2027
  • Where: Vancouver, Canada, and online
  • Focus: Rust and its open-source ecosystem
  • Links: Website

Oxidize (Berlin, Germany)

Oxidize

Oxidize returns to Berlin in September 2027. The organizers announced the dates in their 2026 recap; the conference website still shows the previous edition, so don’t use its venue or ticket details to plan your 2027 trip.

  • When: September 14-16, 2027
  • Where: Berlin, Germany (venue to be announced)
  • Focus: Applied Rust
  • Links: 2027 Announcement | Website

Q4 2027

EuroRust (Prague, Czech Republic + Online)

EuroRust

EuroRust heads to Prague for two days of talks covering Rust patterns and idioms, systems programming, CLI tooling, servers, and WASM. Workshops take place on October 13 at a separate location in the city center, with side activities on October 13, 14, and 16.

  • When: October 13-16, 2027 (main conference October 14-15)
  • Where: Cubex Centrum, Prague, Czech Republic, and online
  • Format: 1 day of workshops, 2 days of talks, and side activities
  • Focus: The European Rust community, covering all things Rust
  • Tickets: Early-bird tickets are on sale. Reduced-price tickets are available for private attendees and freelancers; company-paid attendees need company tickets. Side activities are ticketed separately.
  • Links: Website | Tickets

Rust tracks and workshops

FOSDEM Rust Devroom (Brussels, Belgium)

FOSDEM Rust Devroom

The Rust Devroom returns to FOSDEM for talks on the language, tooling, and Rust’s use in free and open-source software. The organizers have confirmed the 2027 date, even though the devroom website still shows 2026.

  • When: January 31, 2027
  • Where: Brussels, Belgium
  • Format: Rust track within FOSDEM 2027
  • Focus: Rust in free and open-source software
  • Pricing: Free
  • CFP: Open. The announcement gives conflicting deadlines: November 20 and December 1, 2026. Check with the organizers before relying on either date.
  • Links: 2027 CFP Announcement | Devroom Website

Rust Verification Workshop (Copenhagen, Denmark)

Rust Verification Workshop at ETAPS

ETAPS explicitly lists the Rust Verification Workshop as a co-located event for 2027. The workshop’s own dates and program are still to be announced; the main ETAPS dates aren’t a confirmed workshop schedule.

  • When: TBA
  • Where: Copenhagen, Denmark
  • Format: Research workshop, co-located with ETAPS 2027
  • Focus: Verification of Rust programs
  • Links: ETAPS 2027 Announcement

PLRust (Kyoto, Japan)

PLRust — Programming Language Exploration into Rust

The first Workshop on Programming Language Exploration into Rust brings together people working on Rust in programming language research, in academia and industry. Topics include ownership, borrow checking, formal methods, tooling, and education.

  • When: TBA
  • Where: Kyoto, Japan
  • Format: Research workshop alongside ‹Programming› 2027, with 20-25 minute talks
  • Focus: Programming language research involving Rust
  • CFP: Talk proposals are due December 14, 2026, Anywhere on Earth (UTC−12). The workshop page says the submission system is not open yet.
  • Links: Workshop Website and Call for Talks | Event Listing

More conferences to watch

We haven’t found official 2027 dates for the following conferences yet. These are events to keep an eye on, not confirmed 2027 editions:

Want to catch up on past events?

Browse our dedicated Rust conference recordings page where you can filter by year, topic, and conference!

Rust Search conference archive with year and topic filters and video recordings


Missing an event? Spot an error? Feel free to edit this list directly or let us know.

Check the organizers’ websites for the latest details before booking travel. See you at the next conference! 🦀

We Have Named Arguments at Home

2026-09-24 08:00:00

Steve Klabnik recently wrote about named arguments, optional arguments, default arguments, function overloading, and why most of that design space has historically made him nervous in Rust.

I agree with Steve. In fact, I think I agree slightly more strongly than Steve does. :)

I actually think we can get most of what we want without adding any new language features. Instead, we can lean into what Rust already provides.

None of these is an exact substitute for what you get in Python, Ruby, C++, or Kotlin, but that’s sort of the point. Instead, you can get 80% of the ergonomics without adding any magic to function calls at all.

The recurring pattern is that Rust takes something another language puts into function-call semantics and represents it as a normal part of its type system, elegantly sidestepping the mentioned design problems.

Named Arguments at Home

Let’s revisit Steve’s example from the image crate:

pub fn crop_imm<I: GenericImageView>(
    image: &I,
    x: u32,
    y: u32,
    width: u32,
    height: u32,
) -> SubImage<&I> {
    // ...
}

let cropped = image::imageops::crop_imm(&img, 10, 20, 200, 100);

The obvious problem is that four consecutive u32s are not a great API that you can reliably use without reading the docs.

Let’s assume for a moment that we had named arguments:

let cropped = image::imageops::crop_imm(
    image: &img,
    x: 10,
    y: 20,
    width: 200,
    height: 100,
);

That’s clearly better, but stable Rust has another syntax in its place: structs.

struct Crop {
    x: u32,
    y: u32,
    width: u32,
    height: u32,
}

fn crop_imm<I: GenericImageView>(
    image: &I,
    crop: Crop,
) -> SubImage<&I> {
    // ...
}

let cropped = image::imageops::crop_imm(
    &img,
    Crop {
        x: 10,
        y: 20,
        width: 200,
        height: 100,
    },
);

A struct is a named argument with one extra type name. On top of that, we also get arbitrary field order:

Crop {
    width: 200,
    height: 100,
    x: 10,
    y: 20,
}

We also get typo checking, autocomplete, and per-field documentation for free! And we can put invariants on the type and pass the arguments around as values.

And, perhaps most importantly, the names belong to the type, rather than becoming part of every function’s calling convention.

That last property neatly avoids several problems with actual named arguments. Consider function pointers:

fn resize(width: u32, height: u32) {}
fn offset(dx: u32, dy: u32) {}

let f: fn(u32, u32) = if resizing { resize } else { offset };

What would the parameter names of f be? With an argument struct, the question simply wouldn’t come up:

struct Size {
    width: u32,
    height: u32,
}

fn resize(size: Size) {}

// No more arguing about arguments
let f: fn(Size) = resize;

If names are semantically important, give the names a type. If they aren’t, then… don’t.

There is another delightful benefit here. Steve brings up evaluation order:

consume(length: data.len(), data: data);

I.e., should arguments be evaluated in the order they appear at the call site, or in the order the parameters appear in the declaration? Here, does that mean computing data.len() before moving data or after?

Rust already answered this question for structs:

let args = Args {
    length: data.len(),
    data,
};

Expressions are evaluated where you wrote them. No new rules required.

This feels extremely idiomatic to me: rather than teaching function calls a second field-like syntax with subtly different semantics, just use the field syntax that already exists.

Arguments Are Part of Your Domain

Of course, declaring a bespoke argument type for every two-argument function would be ridiculous. I would not write:

struct PushArgs<T> {
    value: T,
}

vec.push(PushArgs { value: 42 });

That would be silly. The trick is to notice that named arguments are most useful exactly where an argument bundle becomes conceptually meaningful, which is the same point at which you reach for a struct anyway.

These are bad:

draw(x1, y1, x2, y2, width, opacity);
connect(host, port, timeout, retries, tls);

And these are often better APIs anyway:

draw(Line {
    start: Point { x: x1, y: y1 },
    end: Point { x: x2, y: y2 },
    width,
    opacity,
});

connect(ConnectionOptions {
    host,
    port,
    timeout,
    retries,
    tls,
});

The design pressure forced us to uncover missing domain concepts.

Optional Arguments at Home

An optional argument is, to some extent, an argument which may or may not exist. Rust has a type for that.

fn connect(url: &str, timeout: Option<Duration>) {
    // ...
}

connect("https://example.com", None);

connect(
    "https://example.com",
    Some(Duration::from_secs(5)),
);

This is not as pleasant as:

connect("https://example.com")
connect("https://example.com", timeout: 5s)

But it has a useful property: the optionality appears in the function’s type. There isn’t a hidden second calling convention for connect. There is but one function:

fn(&str, Option<Duration>)

and every caller supplies both arguments.

This is obviously not what you want once you have six optional arguments:

request(
    url,
    None,
    None,
    Some(timeout),
    None,
    None,
    None,
);

I’ve personally been found guilty of this pattern in the past. The problem is that the arguments have stopped being a parameter list and started being configuration. So:

struct RequestOptions {
    timeout: Option<Duration>,
    proxy: Option<Proxy>,
    redirect: Option<RedirectPolicy>,
    // ...
}

request(
    url,
    RequestOptions {
        timeout: Some(Duration::from_secs(5)),
        proxy: None,
        redirect: None,
    },
);

Instead of optional arguments, we deal with data. And that adds a nice property: there is no special distinction between “arguments supplied syntactically to this invocation” and “options I calculated elsewhere.”

let options = RequestOptions {
    timeout: config.request_timeout,
    proxy: detect_proxy(),
    redirect: None,
};

request(url, options);

It composes nicely because it’s just a value.

Default Arguments at Home

Now the obvious objection: writing all those Nones is terrible.

Correct. So don’t.

That’s why we have Default and struct update syntax:

#[derive(Default)]
struct RequestOptions {
    timeout: Option<Duration>,
    proxy: Option<Proxy>,
    follow_redirects: bool,
}

request(
    url,
    RequestOptions {
        timeout: Some(Duration::from_secs(5)),
        ..Default::default()
    },
);

That is getting awfully close to:

request(url, timeout: 5s)

with one minor wrinkle:

RequestOptions {
    ...
    ..Default::default()
}

That is not nothing. But look at what we didn’t have to add: rules for which arguments may be omitted, how positional and named arguments interact, or whether you can omit something in the middle.

There’s no special syntax for declaring parameter defaults, no question about whether default expressions run at declaration time or invocation time, and no special representation in fn types.

Default is just a trait, and function calls remain untouched.

Defaults are now usable independently of the function:

let defaults = RequestOptions::default();

That is frequently useful in its own right. For library APIs, I often like being slightly more explicit:

struct RequestOptions {
    timeout: Duration,
    follow_redirects: bool,
}

impl Default for RequestOptions {
    fn default() -> Self {
        Self {
            timeout: Duration::from_secs(30),
            follow_redirects: true,
        }
    }
}

Then:

request(
    url,
    RequestOptions {
        timeout: Duration::from_secs(5),
        ..Default::default()
    },
);

I think this gets most of the important bits right.

Builder Pattern for the Really Complex Cases

Sometimes even the options struct is too noisy, often when construction requires validation or conversion.

Then, yes, there is the builder:

let request = Request::builder(url)
    .timeout(Duration::from_secs(5))
    .follow_redirects(false)
    .build()?;

Steve is right that builders should not be the default. They can become their own tiny programming language. But a small builder has a very useful property: each “argument” is an ordinary method call. That means we can do things like:

let mut request = Request::builder(url);

if let Some(timeout) = config.timeout {
    request = request.timeout(timeout);
}

let request = request.build()?;

Doing that with language-level keyword arguments generally requires constructing a map, splatting things, or some other mechanism.

In Rust, it’s method calls. I personally find this very pleasing to read.

Function Overloading at Home

In Java, you can write:

void connect(String url, int timeout) { ... }
void connect(String url) { ... }

Rust doesn’t let you define both:

fn connect(url: &str) {}
fn connect(url: &str, timeout: Duration) {}

I am very happy about this. But there are several different things people mean when they say they want overloading, and Rust already covers most of them separately.

“I Want One Convenience Form and One Configurable Form”

Give them different names:

fn connect(url: &str) {
    connect_with_timeout(url, DEFAULT_TIMEOUT)
}

fn connect_with_timeout(url: &str, timeout: Duration) {
    // ...
}

The standard library does this often. See Vec::new() and Vec::with_capacity(), for example.

This costs the library author one additional name (often just with_...) and saves every user from doing overload resolution in their head.

“I Want Several Input Types”

Use a trait. The standard library does this all the time with traits like Into, AsRef, and Borrow. For example:

fn greet(name: impl AsRef<str>) {
    println!("Hello, {}", name.as_ref());
}

greet("Ferris");
greet(String::from("Ferris"));

This gives us another useful part of overload-like behavior: one API can accept different input types.

For owned conversion:

fn set_name(name: impl Into<String>) {
    let name = name.into();
    // ...
}

That’s just a single function with one parameter list and trait dispatch. And unlike unrestricted overloading, the relationship between accepted types is explicit: they work as long as they satisfy the bound.

“Different Types Need Different Behavior”

That’s a trait, too:

trait Render {
    fn render(self, out: &mut Output);
}

impl Render for &str {
    fn render(self, out: &mut Output) {
        // ...
    }
}

impl Render for Image {
    fn render(self, out: &mut Output) {
        // ...
    }
}

fn render(value: impl Render, out: &mut Output) {
    value.render(out);
}

That’s polymorphism; we just put it in the trait system instead of in name resolution.

Flexible Argument Types at Home

Steve’s Ruby example has this equally lovely and terrifying quality:

redirect_to "http://www.rubyonrails.org"
redirect_to @post
redirect_to action: "show", id: 5

These calls look like they’re invoking one conceptual operation, but they mean wildly different things.

In Rust, we can model that directly:

enum Redirect {
    Url(Url),
    Post(Post),
    Action {
        action: String,
        id: u64,
    },
}

fn redirect_to(target: Redirect) {
    // ...
}

Then:

redirect_to(Redirect::Url(url));

redirect_to(Redirect::Post(post));

redirect_to(Redirect::Action {
    action: "show".into(),
    id: 5,
});

That’s more verbose, but in a good way. And I can ask, “Hey editor, what can I redirect to?” and the editor replies with the variants of Redirect. That’s more helpful than “read the docs and discover which keys and values this hash accepts.”

If we really cared about smoothing down the edges, we could add From conversions:

impl From<Url> for Redirect {
    fn from(url: Url) -> Self {
        Self::Url(url)
    }
}

impl From<Post> for Redirect {
    fn from(post: Post) -> Self {
        Self::Post(post)
    }
}

fn redirect_to(target: impl Into<Redirect>) {
    let target = target.into();
    // ...
}

Now:

redirect_to(url);
redirect_to(post);

And for the structurally interesting case:

redirect_to(Redirect::Action {
    action: "show".into(),
    id: 5,
});

Remember that this has no runtime cost and is fully type-safe. Not bad for a compiled language.

Options Hashes at Home

An “options hash” is basically a dynamically typed anonymous struct. So the extremely boring Rust translation is: use a statically typed, named struct.

Ruby:

redirect_to post_url(@post),
  status: 301,
  flash: { updated_post_id: @post.id }

Rust:

redirect_to(
    post_url(&post),
    RedirectOptions {
        status: StatusCode::MOVED_PERMANENTLY,
        flash: Some(Flash {
            updated_post_id: Some(post.id),
            ..Default::default()
        }),
        ..Default::default()
    },
);

Yes, the Rust version is noisier, but it also detects when we misspell status. It’s impossible to pass a string where the status code goes, and it’s straightforward to list every supported option.

I don’t think Rust should optimize for making the syntax as dense as possible. Instead, if a set of options is common enough to deserve convenient syntax, it is probably common enough to justify a type.

fn redirect_to(target: Url, options: RedirectOptions)

Now, options have a name and the fields can be documented in one place.

Variadic Arguments at Home

Rust does not have general-purpose variadic Rust functions. But once again, it already has several ways of expressing the same concept.

If all arguments have the same type, take a slice:

fn sum(values: &[i32]) -> i32 {
    values.iter().sum()
}

sum(&[1, 2, 3, 4]);

Or accept an iterator:

fn sum(values: impl IntoIterator<Item = i32>) -> i32 {
    values.into_iter().sum()
}

sum([1, 2, 3, 4]);
sum(vec![1, 2, 3, 4]);

That is arguably more composable than:

sum(1, 2, 3, 4)

because the caller can naturally pass an existing collection. (All type-safe, of course, and with zero indirection at runtime.)

If the arguments are heterogeneous, the last resort is to write a custom macro. To be clear, I would not use macros just to fake variadic functions, but I do like how macros can be used in stable Rust, and how the exclamation mark stands out from normal function calls.

Keyword-Looking Syntax at Home

There is one tiny affordance in all of these examples that I think deserves more credit: field-init shorthand.

Rust lets you turn this:

let options = RequestOptions {
    timeout: timeout,
    proxy: proxy,
    retries: retries,
};

into this:

let options = RequestOptions {
    timeout,
    proxy,
    retries,
};

This directly addresses one of Steve’s complaints about keyword arguments:

response_model=response_model,
status_code=status_code,
tags=tags,
dependencies=dependencies,

Rust’s answer is effectively:

Options {
    response_model,
    status_code,
    tags,
    dependencies,
}

In my opinion, that’s even better than keyword arguments. That’s because the labels are still present, the duplication disappears, and nothing needs to change in how we call functions.

Composition Is a Superpower

The thing I like the most about Rust is how each concept nicely interacts with the others. That is not an easy task, and Rust deserves a lot of credit for that.

For example, suppose we want a complex HTTP request API with:

  • a required URL,
  • several accepted URL-like input types,
  • named options,
  • defaults,
  • optional timeout,
  • configurable redirects,
  • and a variable number of headers.

We could imagine a pile of language features that lets us write:

request(
    "/hello",
    timeout: 5s,
    redirects: false,
    headers: [
        ("Accept", "application/json"),
        ("X-Foo", "bar"),
    ],
)

Now wouldn’t that be nice? However, we can already do this in stable Rust with a combination of already existing, composable features:

request(
    "/hello",
    RequestOptions {
        timeout: Some(Duration::from_secs(5)),
        redirects: false,
        headers: vec![
            Header::new("Accept", "application/json"),
            Header::new("X-Foo", "bar"),
        ],
    },
)?;

And all we had to do was write the code we’d likely write anyway:

#[derive(Default)]
struct RequestOptions {
    timeout: Option<Duration>,
    redirects: bool,
    headers: Vec<Header>,
}

fn request(
    url: impl Into<Url>,
    options: RequestOptions,
) -> Result<Response> {
    // ...
}

Or maybe you prefer a builder?

Request::new("/hello")
    .timeout(Duration::from_secs(5))
    .redirects(false)
    .header("Accept", "application/json")
    .header("X-Foo", "bar")
    .send()?;

We combined standard Rust concepts: structs, enums, Option, Default, struct update syntax, field-init shorthand, traits, generics, iterators, and methods. Those mechanisms are all useful far beyond argument passing.

Basic Rust syntax is all the machinery required to build ergonomic APIs. Keeping things simple doesn’t mean worse ergonomics.

Friction Produces Better APIs

The obvious response to everything above is:

Come on. These aren’t actually named/default/overloaded/variadic arguments. They’re workarounds.

Correct.

  • Actual named arguments might let me turn crop_imm(&img, 10, 20, 200, 100) into:
    crop_imm(
        image: &img,
        x: 10,
        y: 20,
        width: 200,
        height: 100,
    );
    That surely is nicer at the call site.
  • Actual default arguments might let me write request(url, timeout: timeout); instead of introducing RequestOptions.
  • Actual overloading might let two functions share the same name instead of forcing me to invent with_timeout.

All of the above might be useful, though localized, syntax improvements. But the hidden tax is that the language becomes more complex, for arguably little gain.

Friction in APIs often pushes us toward solutions that turn out to be useful beyond the original problem:

  • These four coordinates become a Rect.
  • Seven random config parameters become RequestOptions.
  • A bunch of dynamically accepted values turn into an enum.
  • A family of related operations becomes a trait.

In a sense, the concrete issue points to a broader design problem, and resolving it opens up completely new ways to solve similar problems. That’s great systems design.

Rust’s Answer to Almost Everything Is: Better Types

I think there’s a broader design principle behind all of this.

A common design philosophy in dynamic languages is to make familiar constructs more powerful by overloading them with additional semantics. After all, that is one affordance which dynamic typing allows: the ability to decide the meaning of an object at runtime.

foo(x)
foo(x, y)
foo(x, timeout: 3)
foo(path: x, timeout: 3)
foo(x, **options)
foo(*args, **options)

Rust, however, tends to move complexity outward and let the type system do all the work.

foo(FooOptions { ... })

What about Agents?

One might ask: “In the age of agentic development, doesn’t verbosity become cheaper while redundant labels may make a call easier to understand locally?”

I agree with the premise. I’m less sure it changes the conclusion.

An agent looking at:

crop_imm(
    &img,
    Crop {
        x: 10,
        y: 20,
        width: 200,
        height: 100,
    },
);

gets essentially the same local information.

Arguably it gets more: Crop gives the bundle a semantic identity which the function parameter list alone does not.

Similarly:

request(
    url,
    RequestOptions {
        timeout,
        ..Default::default()
    },
);

says something useful to both humans and agents. timeout is not merely an optional syntactic argument to this particular invocation; it is a way to configure a request.

And if agents really do make typing cost increasingly irrelevant, then the principal downside of these slightly-more-verbose Rust idioms gets cheaper too. The robots can type RequestOptions for me.

Keep the Functions Boring

I’m not opposed to Rust ever gaining named arguments. There may be a proposal that finds a tiny, coherent design which handles patterns, function pointers, traits, evaluation order, compatibility, and all the other sharp edges described. But I don’t feel much urgency.

Stable Rust already gives me structs for named options, Option and Default for optional values and defaults, traits and enums for varied inputs, and slices and iterators for repeated arguments.

Collectively, they cover a lot of ground. And they do it by reusing features Rust already needs. And I think that’s a core part of Rust’s design philosophy: finding the smallest, composable, orthogonal set of abstractions, which, when combined, can solve many problems in elegant ways. The whole is greater than the sum of its parts.

Where to go from here

Want a second opinion on your team’s Rust APIs? Let’s review the types and abstractions together.

  • For your team. Training, code review, and architecture support to ship Rust with confidence.
  • For yourself. 1-on-1 mentorship for Rust design, architecture, and code review on real projects.

JetBrains

2026-07-30 08:00:00

Welcome to the final episode of this season of Rust in Production. My guest is Orhun Parmaksız from JetBrains, and we talk about building developer tools with Rust.

JetBrains is best known for IntelliJ IDEA, Kotlin, and a long line of IDEs for professional software teams. In the Rust world, that now includes RustRover: a commercial IDE built on the IntelliJ platform, with deep Rust support for navigation, refactoring, debugging, testing, and large codebases.

This episode is about where Rust fits into that world. We talk about why JetBrains does not plan to rewrite the IntelliJ platform in Rust, why Fleet used Rust for its File System Daemon, how Air builds on parts of Fleet’s architecture, and why JetBrains prefers out-of-process Rust helpers over JNI inside the JVM. We also get into RustRover’s internals: PSI, THIR, MIR-based expression evaluation in the debugger, procedural macro sandboxing, library stubs, parser regression testing, cargo-nextest support, and the practical trade-offs between JetBrains’ indexing model and rust-analyzer’s Salsa-based approach.

Proudly Supported by Svix

Svix makes it easy to send webhooks reliably at any scale. It handles retries, secure signatures, monitoring, replay, and endpoint management so you can focus on your product.

Svix is powered by Rust. Learn more about Svix.

Show Notes

Links From The Episode

Official Links

Understanding Dyn Compatibility

2026-07-29 08:00:00

In Rust, some traits can’t be used as trait objects with dyn Trait.

When a trait can’t be used with dynamic dispatch, we say it’s “not dyn compatible.” [1] This has an impact on how you can use these traits in your code.

I think that’s one area where the Rust compiler could print a more helpful error message.

Fixing the issue is mostly about tradeoffs between compile-time generics and runtime polymorphism and learning when each one fits. Once you understand the concept, you’ll know how to get around the issues by choosing a better design for your trait.

Quick Help

If the compiler told you a trait is “not dyn compatible”, your trait can’t be used as dyn Trait because it has a method that can’t go through dynamic dispatch, usually one that returns Self, takes no self, or is generic.

To fix it, pick one:

  • add where Self: Sized to the offending method
  • return Box<dyn Trait> instead of Self
  • use generics instead of &dyn Trait
  • split the trait in two

Continue reading to understand the tradeoffs between each approach.

The Error Message

Here’s an example with code that won’t compile.

Say you have a trait Widget that has a method returning a copy of itself:

trait Widget {
    fn draw(&self);
    fn duplicate(&self) -> Self;  // Returns a copy of itself
}

…and there’s a button, which implements Widget:

struct Button {
    label: String,
}

impl Widget for Button {
    fn draw(&self) {
        // ...
    }

    fn duplicate(&self) -> Self {
        Button { label: self.label.clone() }
    }
}

fn show_widget(widget: &dyn Widget) {
    // This works
    widget.draw();

    // This produces an error because duplicate returns `Self`
    let copy = widget.duplicate();
    copy.draw();
}

If you tried to compile this code, you’d get an error like this:

error[E0038]: the trait `Widget` is not dyn compatible
  --> src/main.rs:20:25
   |
20 | fn show_widget(widget: &dyn Widget) {
   |                         ^^^^^^^^^^ `Widget` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
  --> src/main.rs:3:28
   |
 1 | trait Widget {
   |       ------ this trait is not dyn compatible...
 2 |     fn draw(&self);
 3 |     fn duplicate(&self) -> Self;  // Returns a copy of itself
   |                            ^^^^ ...because method `duplicate` references the `Self` type in its return type
   = help: consider moving `duplicate` to another trait
   = help: only type `Button` implements `Widget`; consider using it directly instead.

That all sounds pretty confusing.

  • What does “not dyn compatible” mean?
  • Shouldn’t the dyn part take care of it?
  • What’s a “vtable”, and why does the trait need to “allow building” one?
  • What does it have to do with Self?

What’s going on?

When you use &dyn Trait, Rust creates a trait object. Trait objects use dynamic dispatch to call methods at runtime. Dynamic dispatch just means that the exact method to call is determined at runtime based on the actual type of the object.

For dynamic dispatch to work, the trait’s dispatchable API must follow certain rules.

  1. Dispatchable methods must not return Self.
  2. Dispatchable methods must have an allowed receiver (&self, &mut self, Box<Self>, and a few related pointer forms). Plain static methods don’t have one.
  3. Dispatchable methods must not have generic type parameters.

These are simplifications: each method-level rule is really “…unless that method opts out with where Self: Sized”, which we’ll see in a moment. Traits also have a few item-level restrictions, such as no associated constants; we’ll summarize the fuller list later. For now, the rough version is enough to build intuition.

In our example, we violate the first rule: the duplicate method returns Self, which means “the same type as the implementor of the trait”. When you use &dyn Widget, the concrete implementor is hidden behind the trait-object interface. The vtable still points to the right concrete implementation, but the call site has no single concrete return type it can name for duplicate. That’s a problem, because the compiler needs to know the size of the return value at compile time, and Self could be any size. It needs to know the size, because the returned value has to live somewhere: the caller sets aside exactly the right amount of space (usually on the stack) before the call even happens. With a &dyn Widget, the concrete type is erased from the caller’s static type, so there’s no single size the compiler could reserve for it.

It will become clearer once we look at some fixes.

How To Fix It

Don’t worry, we won’t have to refactor all our code! All fixes use the same Widget trait example. There are multiple ways to make it dyn compatible.

We have a bunch of options:

  1. Use generics instead
  2. Opt out problematic methods with where Self: Sized
  3. Return boxed trait objects instead of Self
  4. Split into two traits

Each approach comes with different tradeoffs. Depending on the kind of dyn-compatibility issue, one might fit better than the others, or you might combine a few. Let’s look at each of these in detail.

Fix #1: Use Generics Instead

One common way to fix the problem is to use generics instead of trait objects. Generics resolve to concrete types at compile time, so the compiler knows the size of Self. The compiler generates a separate copy of the function for each concrete type that implements the trait. Then, at runtime, you no longer need to worry about any dynamic dispatch (which means “figuring out the type at runtime”). The compiler always knows which type it is dealing with, so it can pick the right method to call.

Our trait stays the same:

trait Widget {
    fn draw(&self);
    fn duplicate(&self) -> Self;
}

But now we change the function which uses the trait to use generics instead of dyn:

// Instead of: fn show_widget(widget: &dyn Widget)
// use generics:
fn show_widget<W: Widget>(widget: &W) {
    widget.draw();
    let copy = widget.duplicate();
    copy.draw();
}

Note how we changed the function signature to use a generic type parameter W that implements the Widget trait. Here we tell Rust: “I have some type W that implements Widget, and I want to use it.” Rust then generates the necessary code for each type used.

That’s close to using &dyn Widget, but not quite the same. The difference is that with generics, the compiler knows the concrete type at compile time, so it can handle Self correctly. For instance, we might know that W is Button in this case, so duplicate returns a Button. Now the confusion about what Self means is gone!

The downside is that you can’t fully lean on dynamic dispatch anymore, and you might have to refactor a lot of code if you were using trait objects extensively before. Your binary size might also grow because of all the copies of the function that the compiler generates for each concrete type.

What’s the benefit of fully leaning on dynamic dispatch?

Fair question! Dynamic dispatch has a bunch of really nice properties:

  • It’s very flexible. You can swap out implementations at runtime, which is great for plugins or when you want to change behavior without recompiling.
  • It allows for polymorphism. You can treat different types that implement the same trait uniformly, which can simplify code that needs to work with various types. You could technically do the same with generics, but sometimes you can’t afford the increase in code size or compile times that come with monomorphization.
  • It can lead to cleaner and more maintainable code in certain scenarios, especially when dealing with complex hierarchies of types and behaviors. For example, take a graphics rendering engine where you have different shapes (circles, squares, triangles) that all implement a Drawable trait. Using dynamic dispatch, you can store them all in a single collection and call draw(). If you were to try the same with generics, you’d end up with a lot of boilerplate code to handle each shape type separately.

Fix #2: Opt Out Problematic Methods with where Self: Sized

Another option is to keep using trait objects but change the problematic method to only work with concrete types.

trait Widget {
    fn draw(&self);

    // Only available when the concrete type is known
    fn duplicate(&self) -> Self
    where
        Self: Sized;
}

This means “this method can only be called when Self has a known size at compile time”, which is true for concrete types but not for trait objects. It’s more explicit, since you control how the trait can be used. The catch is that it limits the trait further down the line: some methods won’t be callable on every trait object, and changing the trait later becomes a breaking change.

You won’t be able to call duplicate on &dyn Widget, but you can still call it on concrete types like Button.

fn main() {
    let button = Button { label: "Click me".to_string() };

    // Can use as trait object now!
    let widget: &dyn Widget = &button;
    widget.draw();  // ✅ Works

    // ❌ Can't call this on trait objects
    // widget.duplicate();

    // ✅ But duplicate still works on concrete types:
    let button2 = button.duplicate();
}

So you keep most of the flexibility of trait objects (unlike with generics), as long as you remember that some methods won’t be available through dyn Trait.

Fix #3: Return Boxed Trait Objects Instead of Self

We can change the return type of the problematic method to return a boxed trait object instead of Self.

This works because Box<dyn Widget> has a known size at compile time. It’s a pointer to an object on the heap. It’s actually a fat pointer: two words wide, or 16 bytes on a 64-bit system, because it also stores a pointer to the vtable; more on that later. What matters is that this size is fixed and known at compile time, unlike Self, which varies based on the concrete type.

trait Widget {
    fn draw(&self);
    fn duplicate(&self) -> Box<dyn Widget>;  // Returns trait object instead of Self
}
struct Button {
    label: String,
}

impl Widget for Button {
    fn draw(&self) {
        println!("Button: {}", self.label);
    }

    fn duplicate(&self) -> Box<dyn Widget> {
        Box::new(Button { label: self.label.clone() })
    }
}

fn main() {
    // Now we can use trait objects!
    let widgets: Vec<Box<dyn Widget>> = vec![
        Box::new(Button { label: "Click me".to_string() }),
        Box::new(Button { label: "Submit".to_string() }),
    ];

    for widget in &widgets {
        widget.draw();
        let copy = widget.duplicate();
        copy.draw();
    }
}

The downside is that Box<dyn> tends to be viral in your codebase. You’ll end up writing Box<dyn Widget> more often than you’d like, which gets noisy.

On top of that, this fix only works for methods that return Self. If your trait also has static methods or generic methods, you’ll need to combine this approach with one of the other fixes.

Fix #4: Split Into Two Traits

Sometimes the best solution is to separate the dyn-compatible methods from the problematic ones into different traits.

Maybe your code is silently trying to tell you that you are mixing up two different responsibilities and that they should be untangled.

In general, prefer smaller, focused traits over large, monolithic ones. Traits are not interfaces! Instead, we lean on composition and focus on behavior rather than mangling multiple ideas into a single trait.

Here’s a more realistic example: separating rendering from widget creation. Factory methods are often static (no self parameter), which makes them incompatible with dyn. So we split them off into a separate trait.

// This trait can be used with dyn
trait Widget {
    fn draw(&self);
}

// Separate trait for creating widgets. Can't be used with `dyn`
trait WidgetFactory {
    fn create(label: String) -> Self;  // No self parameter!
}

struct Button {
    label: String,
}

impl Widget for Button {
    fn draw(&self) {
        println!("Button: {}", self.label);
    }
}

impl WidgetFactory for Button {
    fn create(label: String) -> Self {
        Button { label }
    }
}

fn main() {
    // Use the factory to create widgets
    let button = Button::create("Click me".to_string());

    // Use as trait object for drawing
    let widget: &dyn Widget = &button;
    widget.draw();  // ✅ Works

    // Can't do this: let factory: &dyn WidgetFactory = ...
    // But that's fine - factories work at compile time
}

What’s Going On Under the Hood?

When you write &dyn Trait, you’re creating a trait object. It’s a special kind of value that consists of two pointers (a “fat pointer”):

┌─────────────────┐
│  Data Pointer   │ --> points to actual data (String, i32, etc.)
├─────────────────┤
│ VTable Pointer  │ --> points to virtual method table
└─────────────────┘

As you can see, a trait object has:

  1. A data pointer that points to the actual data (the concrete type implementing the trait)
  2. A vtable pointer that points to a table of function pointers for the methods

The vtable is created at compile time and contains pointers to the methods for the specific type. It is common in many programming languages that support dynamic dispatch, such as C++, C#, or D. When you call a method on a trait object, Rust uses the vtable to look up the correct function to call based on the actual type of the data.

For dynamic dispatch to be sound, the vtable-facing methods need stable, concrete function signatures:

  • every dispatchable method needs a receiver that leads to the object and its vtable
  • argument and return types must be expressible without knowing the hidden concrete Self
  • the vtable must contain a finite set of function pointers, known at compile time

If a trait has dispatchable methods that return Self or have generic parameters, there is no single vtable entry with one concrete signature that can represent all possible calls.

That is the root cause of dyn compatibility issues.

A trait is dyn compatible if it follows a list of rules.

Click here for the full list.
Rule Why?
All supertraits must also be dyn compatible A dyn Subtrait also exposes the supertrait API, so those inherited methods must be dispatchable too
No Self: Sized supertrait The trait object type dyn Trait is unsized, so the trait itself must not require Self: Sized
No associated constants Associated constants are not entries in the method vtable
No generic associated types The Reference currently forbids associated types with generics on dyn-compatible traits
Dispatchable methods must have an allowed receiver Methods need a receiver: &self, &mut self, or pointer receivers like Box<Self>, Rc<Self>, Arc<Self>, or Pin<P> where P is one of those pointer forms. Static methods (no receiver) can’t be dispatched through a trait object
No generic type parameters on dispatchable methods The vtable is a finite structure created at compile time. Generic methods are monomorphized at compile time (one copy per concrete instantiation), but a trait object erases the concrete receiver type
No Self in dispatchable method parameters except the receiver other: &Self means “the same concrete type as self”, but with trait objects we only know both are dyn Comparable; they could hide different underlying types
No Self return type on dispatchable methods The caller needs to know the return value’s size and type, but Self could be any implementor
No opaque return type on dispatchable methods async fn and return-position impl Trait hide a concrete return type that must be known statically
Non-dispatchable methods must opt out A method that violates the dispatch rules can still live on the trait if it has where Self: Sized, making it unavailable through dyn Trait

The rules boil down to the same core issue: the dyn Trait interface must have a finite, statically-known shape even though the concrete implementor behind it is hidden.

A Modern Gotcha: async fn in Traits

Since Rust 1.75, you can write async fn directly in a trait. But there’s a catch: a trait with an async fn is not dyn compatible.

An async fn desugars to a regular method that returns impl Future<...>, a hidden return-position impl Trait. The type is called “opaque”, because we don’t know what it is, and the compiler doesn’t expose it to us. Opaque return types aren’t dispatchable (which means we can’t put them in a vtable of functions), so the trait can’t be used behind dyn.

If you need dynamic dispatch with async methods today, you have a few options:

  • Box the future yourself and return Pin<Box<dyn Future<Output = ...>>>.
  • Use the async-trait crate, which does that boxing for you.
  • Use the dynosaur crate, which generates a dyn-compatible wrapper for traits with async fn.

Summary

Dyn compatibility determines if a trait can be used with dyn Trait. The rules exist because:

  1. Trait objects use dynamic dispatch via vtables
  2. Vtables are static, compile-time structures, which hold method pointers
  3. Type information is erased at runtime to allow polymorphism
  4. The compiler must guarantee type safety at all times, even if it can’t see the concrete type

If your trait is not dyn compatible, don’t worry! Many standard library traits (Clone, Default, etc.) are also not dyn compatible. As we’ve seen, there are ways to work around these limitations with type erasure, generics, or more fine-grained traits.

Which fix to reach for depends on what your trait needs and what you’re willing to give up:

Fix Reach for it when… The tradeoff
#1 Generics (<W: Widget>) You don’t actually need trait objects (the concrete type is known at each call site) and you won’t mix different types in one collection Static dispatch only; monomorphization can grow code size and compile times and generic parameters need to be passed around in your API
#2 where Self: Sized You want to keep using dyn Widget, and the problematic method only ever needs to be called on concrete types That method isn’t callable through dyn; tightening the bound later is a breaking change
#3 Return Box<dyn Widget> The method returns Self and you really need it through a trait object (e.g. a heterogeneous Vec<Box<dyn Widget>>) A heap allocation per call, and Box<dyn> tends to spread through your API
#4 Split into two traits The trait mixes dispatchable behavior with non-dispatchable bits, like static factory methods More traits to keep track of, though that separation often helps
async-trait / dynosaur Your trait has async fns and you need to call them through dyn Wrapper types and usually boxed futures/extra indirection until native dyn async improves

In practice you’ll often combine these. For example, splitting a trait and boxing a return value.

Historical Notes

I find it interesting to see how dyn compatibility evolved over time in Rust. If you do, too, here are some resources to dig deeper:

  • 2014-09-22: RFC 255 - Introduced object safety (2014, before Rust 1.0)
  • 2014-11-03: Issue #428 - Object-safety and static methods
  • 2015-01-03: RFC 546 - Removed implied Sized bound on traits
  • 2023-08-24: Rust 1.72 - GATs can be opted out with where Self: Sized
  • 2023-12-28: Rust 1.75.0 - Stabilized async fn and return-position impl Trait in traits (though such traits still aren’t dyn compatible)
  • 2025-01-09: Rust 1.84.0 - The docs had moved from “object safety” to “dyn compatibility” around this release cycle; the tracking issue notes that the rename unfortunately missed the release notes.

The lang team also wants a “practical path” to call async fns through dyn Trait natively. It’s on the 2026 project goals, so the async gotcha above should ease over time.

Where to go from here

Stuck choosing between generics, trait objects, and enums? I can help you work through the tradeoffs in your own APIs.

  • For your team. Training, code review, and architecture support to ship Rust with confidence.
  • For yourself. 1-on-1 mentorship for Rust design, architecture, and code review on real projects.
  1. The concept used to be called “object safety” until Rust 1.84.0. If you’re reading older resources, they mean the same thing. The name got changed because it was confusing.

    “Object safety” suggests that Rust has “objects” in the traditional OOP sense and that the term is about “safety”, which is misleading. The new term “dyn compatibility” does a better job of saying that it’s about whether a trait can be used with dyn Trait for dynamic dispatch. I still don’t love either term, but I also can’t think of a better name that is both short and accurate. ↩