Typed IDs with SeaORM

TLDR: Check out sea-orm-typed-id for a solution to typed IDs in SeaORM.

After working with various ORMs across languages — ActiveRecord, SQLAlchemy, Diesel, PrismaSeaORM has been a breath of fresh air. Most aspects of it just feel right. However, one thing I missed is the ability to have typed IDs for entities to avoid potential errors, like accidentally swapping IDs between functions.

Imagine a function that takes both a game_id and a player_id as arguments:

async fn find_player(game_id: i32, player_id: i32) -> Result<Player, DbErr> {
    // ...
}

It’s easy to accidentally swap these two arguments:

find_player(game.id, player.id);

// or

find_player(player.id, game.id); // oops!

To avoid such errors, we can leverage Rust’s New Type Idiom to define strongly-typed IDs:

pub struct CakeId(i32);

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "cakes")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: CakeId,
}

However, using CakeId directly as an ID type results in compilation errors due to missing trait implementations:

...
error: could not compile `entities` (lib) due to 32 previous errors

The error reveals several missing traits, including PartialEq, Eq, Clone, Debug, and specific SeaORM traits like TryFromU64 and FromValueTuple.

Making it work

To address these errors, we need to implement the necessary traits for our CakeId type:

#[derive(
    Clone,
    Debug,
    Eq,
    PartialEq,
    sea_orm::DeriveValueType,
)]
pub struct CakeId(i32);

impl sea_orm::TryFromU64 for CakeId {
    fn try_from_u64(n: u64) -> Result<Self, DbErr> {
        Ok(CakeId(i32::try_from_u64(n)?))
    }
}

We also probably want to handle conversions between CakeId and primitive types like i32 (which allows us to write CakeId::from(123) or 123.into()):

impl From<CakeId> for i32 {
    fn from(value: CakeId) -> Self {
        value.0
    }
}
impl From<&CakeId> for i32 {
    fn from(value: &CakeId) -> Self {
        value.0
    }
}
impl From<i32> for CakeId {
    fn from(value: i32) -> Self {
        CakeId(value)
    }
}
impl From<&i32> for CakeId {
    fn from(value: &i32) -> Self {
        CakeId(*value)
    }
}

And to support serialization:

#[derive(
    // ...
    serde::Deserialize,
    serde::Serialize,
)]
pub struct CakeId(i32);

// ...

impl From<CakeId> for sea_orm::JsonValue {
    fn from(value: CakeId) -> Self {
        sea_orm::JsonValue::Number(value.0.into())
    }
}

Automating the Boilerplate

To avoid writing this boilerplate for every ID type, we can create a macro to generate the necessary code:

#[macro_export]
macro_rules! define_id {
    ($name:ident) => {
        #[derive(
            // ...
        )]
        #[repr(transparent)]
        pub struct $name(i32);

        // impl ...
    };
}

Eventually, you may end up with a reusable solution like sea-orm-typed-id. You can use it as a crate or copy it into your project and adapt it to your needs.

Typed IDs help prevent bugs and improve code clarity — an invaluable addition when working with complex applications.