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)
}
}
use serde::de::{Deserialize, Deserializer};
use serde::ser::{Serialize, Serializer};
use uuid::Uuid;
use crate::id::Id;
pub enum User {}
pub enum Order {}
pub type UserId = Id<User>;
pub type OrderId = Id<Order>;
impl<T> Serialize for Id<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.as_uuid().serialize(serializer)
}
}
impl<'de, T> Deserialize<'de> for Id<T> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Uuid::deserialize(deserializer)?;
Ok(Id::from_uuid(value))
}
}
pub struct OrderRow {
pub id: OrderId,
pub owner: UserId,
pub total_cents: i64,
}
use sqlx::PgPool;
use crate::entities::{OrderId, OrderRow, UserId};
use crate::id::Id;
pub async fn fetch_order_for_user(
pool: &PgPool,
user: UserId,
order: OrderId,
) -> Result<Option<OrderRow>, sqlx::Error> {
let record = sqlx::query!(
"SELECT id, owner_id, total_cents \
FROM orders WHERE id = $1 AND owner_id = $2",
order.into_uuid(),
user.into_uuid(),
)
.fetch_optional(pool)
.await?;
Ok(record.map(|r| OrderRow {
id: Id::from_uuid(r.id),
owner: Id::from_uuid(r.owner_id),
total_cents: r.total_cents,
}))
}
pub async fn total_for_user(pool: &PgPool, user: UserId) -> Result<i64, sqlx::Error> {
let sum = sqlx::query_scalar!(
"SELECT COALESCE(SUM(total_cents), 0) FROM orders WHERE owner_id = $1",
user.into_uuid(),
)
.fetch_one(pool)
.await?;
Ok(sum.unwrap_or(0))
}
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
struct Config<'a> {
name: &'a str,
value: &'a str,
}
fn parse_config(line: &str) -> Config {
Lifetime annotations for flexible borrowing in structs
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
macro_rules! create_function {
($func_name:ident) => {
fn $func_name() {
println!("Called {}", stringify!($func_name));
}
};
Declarative macros (macro_rules!) for code generation
Share this code
Here's the card — post it anywhere.