I like the Extensions type map from http, and recently needed the same thing in my own code. Two things got in the way: I didn't want to depend on an HTTP crate just for a type map, and inserting into http::Extensions needs &mut self. That doesn't work when the object is shared, e.g. behind an Arc and used concurrently.

So I wrote OnceTypeMap: think of it as a OnceLock per type. Values are set through &self (get_or_init, insert) and returned as plain &T tied to the borrow of the map, with no lock guard, so an owning struct can still expose fn foo(&self) -> &Foo.

What I'd like feedback on

  1. Soundness of the unsafe. Is the reasoning below sound? Is anything missing?
  2. Prior art. Is there an existing crate I should have used instead? I found http::Extensions, which needs &mut for inserts, so it does not fit shared caches. I'd also like to hear about anything that gives &T from &self. I looked at state and it lacks get_or_init useful for lazy init.
  3. Fallible and async initialization. I left these to the stored value: store a Result, a once_cell::sync::OnceCell (for get_or_try_init), or a tokio::sync::OnceCell. There is an example for each. Is that a reasonable split, or should the map offer them itself?

My use case. I'm building several SDK-like crates on top of a shared Client from a foundation crate. The Client handles connectivity and auth for all of them, but each SDK needs to cache facts about the connection that the Client doesn't know about (API version, handles to remote objects, and so on). With OnceTypeMap, each crate attaches that state through an extension trait. See examples/extensions.rs.

The unsafe. This is my first crate with unsafe code. All of it goes through one private function, detach, which extends a slot reference from the lock guard's lifetime to the borrow of the map. The argument:

  • Each value lives in its own heap-allocated OnceLock<T> slot. Through &self a slot can only be created and filled once. It is never moved, replaced or dropped.
  • Removing or mutating anything (remove, clear, get_mut) needs &mut self, and dropping or moving the map needs ownership. The borrow checker rejects all of these while a &T from the map is alive. This is analogous to why OnceLock::get_mut and take are safe.
  • The map is shared, not frozen, while a &T is alive. Other threads can still add new types, and the inner HashMap may resize and move its entries. So the slots must be owned through something whose move doesn't invalidate references into the slot. I own each slot through a never-cloned Arc<dyn Slot> instead of a Box, because whether moving a Box invalidates references into it I understand is unsettled in Rust's aliasing rules. (Miri doesn't flag the Box version today. I changed it to avoid ambiguity.)
  • The write-locked path (create_slot) can hit an already-occupied entry if another thread won the race, and it only hands out a &mut to the Arc, not to the OnceLock inside.

CI runs Miri under both Stacked Borrows and Tree Borrows, including a test that keeps a &T alive while every slot is moved through typed code.

Known limitations: one value per type (use newtypes), T: Send + Sync + 'static, and calling get_or_init::<T> from inside the initializer for the same T deadlocks (as with OnceLock). Cyclic dependencies between initializers do too. Both are documented.

Disclosure. I wrote the requirements and a first draft of the code. LLMs helped a lot with the rest: polishing the library, writing tests and docs, writing the examples, and working through the safety argument. I've reviewed all of it, and that is why I am asking for human eyes on the unsafe part. The design isn't very original: it is influenced by OnceLock and http::Extensions. The MSRV is Rust 1.86, because I use trait upcasting (dyn Slot to dyn Any) instead of hand-written as_any helpers.

1 post - 1 participant

Read full topic