rust 127 lines · 3 tabs

Type-Safe Entity IDs in Rust with a Zero-Cost Id<T> Newtype

Shared by codesnips Jul 2026
3 tabs
use std::fmt;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
use uuid::Uuid;

pub struct Id<T> {
    value: Uuid,
    _marker: PhantomData<fn() -> T>,
}

impl<T> Id<T> {
    pub fn new() -> Self {
        Self::from_uuid(Uuid::new_v4())
    }

    pub fn from_uuid(value: Uuid) -> Self {
        Id { value, _marker: PhantomData }
    }

    pub fn into_uuid(self) -> Uuid {
        self.value
    }

    pub fn as_uuid(&self) -> &Uuid {
        &self.value
    }
}

impl<T> Clone for Id<T> {
    fn clone(&self) -> Self {
        *self
    }
}

impl<T> Copy for Id<T> {}

impl<T> PartialEq for Id<T> {
    fn eq(&self, other: &Self) -> bool {
        self.value == other.value
    }
}

impl<T> Eq for Id<T> {}

impl<T> Hash for Id<T> {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.value.hash(state);
    }
}

impl<T> fmt::Display for Id<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.value)
    }
}
3 files · rust Explain with highlit

Passing raw i64 or Uuid values around a codebase is a common source of subtle bugs: nothing stops a user_id from being handed to a function that expects an order_id, and the compiler happily accepts it. This snippet shows the classic newtype-over-a-phantom-parameter pattern that gives every entity its own distinct ID type while sharing a single implementation.

In id.rs, Id<T> wraps a plain Uuid and carries a PhantomData<fn() -> T> marker so the type parameter influences the type without occupying any memory — the wrapper is zero-cost and has the same layout as a bare Uuid. Using fn() -> T rather than PhantomData<T> keeps Id<T> Send/Sync and covariant regardless of what T is, which matters because T is only ever a tag, never actually stored. The manual impl blocks for Clone, Copy, PartialEq, and Hash are deliberate: #[derive] would add an unwanted T: Clone bound, so the traits are implemented by hand to depend only on the inner Uuid. Constructors new, from_uuid, and into_uuid form the only bridge between raw UUIDs and typed IDs, so conversions are explicit and greppable.

Because the marker types never need values, entities.rs declares them as empty enum User {} and enum Order {}. Empty enums are uninhabited, making it impossible to accidentally construct a tag, and the type UserId = Id<User> aliases give ergonomic names. The Serialize/Deserialize impls forward straight to Uuid, so a UserId is indistinguishable from a plain UUID on the wire while remaining distinct in Rust's type system.

repo.rs demonstrates the payoff. fetch_order_for_user accepts a UserId and an OrderId, and the sqlx bindings call .into_uuid() explicitly. If a caller swaps the arguments, the code fails to compile rather than querying the wrong table at runtime. The trade-off is a little boilerplate and the need to unwrap at true system boundaries — HTTP params, SQL rows — but everywhere in between the compiler enforces that IDs of different entities never mix.


Related snips

Share this code

Here's the card — post it anywhere.

Type-Safe Entity IDs in Rust with a Zero-Cost Id<T> Newtype — share card
Link copied