Skip to content
rusthacks.dev
Databases

Newtypes for Database IDs in Rust to Stop Mixing Them Up

Wrap IDs in tiny types so a UserId cannot be passed where an OrderId is expected. Derive sqlx::Type and Serde with transparent, and keep the cost at zero.

2 min read
Summarize in AI
Flat illustration for the post “Newtypes for Database IDs in Rust to Stop Mixing Them Up”, composed around database.

fn transfer(from: i64, to: i64, amount: i64) compiles when you swap all three arguments. Database code is full of integers that mean different things, and the compiler cannot tell them apart unless you tell it. A newtype is the cheapest fix Rust offers: one line per ID type, no run time cost. This post adds the derives that make it work with SQLx and Serde. It is a design post in the SQLx explained cluster.

sqlx 0.8, serde 1, Rust 1.90.

Define the types

rust
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct UserId(pub i64);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct OrderId(pub i64);

impl std::fmt::Display for UserId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

sqlx::Type with transparent tells SQLx the wrapper has the database type of the inner value. serde(transparent) makes JSON {"user": 7} and not {"user": [7]}.

Use them

rust
use sqlx::PgPool;

pub async fn orders_for(pool: &PgPool, user: UserId) -> Result<Vec<OrderId>, sqlx::Error> {
    sqlx::query_scalar("SELECT id FROM orders WHERE user_id = $1")
        .bind(user)
        .fetch_all(pool)
        .await
}

bind(user) works because UserId: Encode. The result decodes into Vec<OrderId>. Now compare the compiler's reaction to a swap:

rust
fn cancel(order: OrderId, by: UserId) {}
// cancel(user, order);

That commented call fails with E0308: expected OrderId, found UserId, which is a bug caught during compilation instead of in production. See mismatched types.

Less boilerplate

For several types, a small macro avoids repeating the derives:

rust
macro_rules! id_type {
    ($name:ident) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
        #[serde(transparent)]
        #[sqlx(transparent)]
        pub struct $name(pub i64);
    };
}

id_type!(ProductId);
id_type!(InvoiceId);

Macro basics are in macro rules basics.

With queries that map to structs

Fields of type UserId in a FromRow struct decode directly, so the typed IDs flow from the database to the handler without a conversion. In the query! macros, an override like id AS "id: UserId" picks the type.

Where it gets in the way

Arithmetic and sorting on the raw value need .0, and you decide which operations to expose. If an ID is a UUID, the same pattern works around uuid::Uuid, as discussed in UUIDs versus integer keys. Keep the inner field public only if there is no invariant to protect. The technique is described in the Rust API guidelines as newtypes provide static distinctions.

Also inGenerics
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.