Typed IDs with SeaORM
Updated
TLDR: Check out sea-orm-typed-id for a solution to typed IDs in SeaORM.
Updated for SeaORM 2 and sea-orm-typed-id 0.6.2.
After working with various ORMs across languages — ActiveRecord, SQLAlchemy, Diesel, Prisma — SeaORM 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 this bare CakeId as an ID type results in compilation errors. It needs traits such as PartialEq, Eq, Clone, and Debug, along with implementations for reading and writing database values.
Making it work
To address these errors, we can derive the necessary traits for our CakeId type:
#[derive(
Clone,
Debug,
Eq,
PartialEq,
sea_orm::DeriveValueType,
)]
pub struct CakeId(i32);
In SeaORM 2, DeriveValueType also implements TryFromU64 for integer wrappers such as this one. The original version of this post implemented that trait manually; keeping that implementation alongside the derive now causes a conflicting implementation error.
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.
Using the library
Version 0.6.2 targets SeaORM 2. Add these dependencies, alongside the SeaORM database driver and runtime features your application uses:
[dependencies]
sea-orm = { version = "2", default-features = false, features = ["macros", "with-json", "postgres-array"] }
sea-orm-typed-id = "0.6.2"
serde = { version = "1", features = ["derive"] }
The macro generates the ID type, primitive conversions, Serde support, and SeaORM integration:
use sea_orm_typed_id::define_id;
define_id!(CakeId); // i32, equivalent to define_id!(CakeId, i32)
define_id!(FillingId, i64); // i64, mapped to a BIGINT column
let cake_id = CakeId::from(123);
let filling_id = FillingId::from(4_000_000_000_i64);
Use the same ID type for an entity’s primary key and any foreign keys that reference it. A function taking CakeId cannot accidentally receive a FillingId.
For auto-incrementing PostgreSQL primary keys, SeaORM 2 uses identity columns by default. If you specifically need BIGSERIAL for an i64 ID, enable SeaORM’s postgres-use-serial-pk feature.
PostgreSQL arrays and schemas
Since version 0.6.1, the library also supports PostgreSQL arrays of typed IDs. An entity can include a field such as:
pub filling_ids: Vec<FillingId>,
The macro adds #[sea_orm(try_getable_array)], and the library enables SeaORM’s postgres-array feature. There is no need to store a Vec<i64> and convert it through a getter.
For API schemas, enable the library’s optional schema feature for schemars::JsonSchema, utoipa for OpenAPI schemas, or all for both. Add the corresponding schemars or utoipa dependency to your application as well, since the generated code refers to it directly. Serde serialization is included without either optional feature.
Typed IDs help prevent bugs and improve code clarity — an invaluable addition when working with complex applications.