type_safe_id/
lib.rs

1//! A type-safe, K-sortable, globally unique identifier
2//!
3//! ```
4//! use type_safe_id::{StaticType, TypeSafeId};
5//!
6//! #[derive(Default)]
7//! struct User;
8//!
9//! impl StaticType for User {
10//!     // must be lowercase ascii [a-z] only
11//!     const TYPE: &'static str = "user";
12//! }
13//!
14//! // type alias for your custom typed id
15//! type UserId = TypeSafeId<User>;
16//!
17//! let user_id1 = UserId::new();
18//! # std::thread::sleep(std::time::Duration::from_millis(10));
19//! let user_id2 = UserId::new();
20//!
21//! let uid1 = user_id1.to_string();
22//! let uid2 = user_id2.to_string();
23//! dbg!(&uid1, &uid2);
24//! assert!(uid2 > uid1, "type safe IDs are ordered");
25//!
26//! let user_id3: UserId = uid1.parse().expect("invalid user id");
27//! let user_id4: UserId = uid2.parse().expect("invalid user id");
28//!
29//! assert_eq!(user_id1.uuid(), user_id3.uuid(), "round trip works");
30//! assert_eq!(user_id2.uuid(), user_id4.uuid(), "round trip works");
31//! ```
32#![cfg_attr(docsrs, feature(doc_cfg))]
33#![forbid(unsafe_code)]
34
35#[cfg(feature = "arbitrary")]
36mod arbitrary;
37
38#[cfg(feature = "serde")]
39mod serde;
40
41use std::hash::Hash;
42use std::{borrow::Cow, fmt, str::FromStr};
43
44use arrayvec::ArrayString;
45use uuid::{NoContext, Uuid};
46
47#[non_exhaustive]
48#[derive(Debug, thiserror::Error)]
49pub enum Error {
50    /// The ID type was not valid
51    #[error("id type is invalid")]
52    InvalidType,
53    /// The ID type did not match the expected type
54    #[error("id type {actual:?} does not match expected {expected:?}")]
55    IncorrectType {
56        actual: String,
57        expected: Cow<'static, str>,
58    },
59    /// The ID suffix was not valid
60    #[error("id suffix is invalid")]
61    InvalidData,
62    /// The string was not formed as a type-id
63    #[error("string is not a type-id")]
64    NotATypeId,
65}
66
67/// A static type prefix
68pub trait StaticType: Default {
69    /// must be lowercase ascii [a-z_] only, under 64 characters.
70    /// first character cannot be an underscore
71    const TYPE: &'static str;
72
73    #[doc(hidden)]
74    const __TYPE_PREFIX_IS_VALID: bool = {
75        assert!(Self::TYPE.len() < 64);
76        let mut i = 0;
77        while i < Self::TYPE.len() {
78            let b = Self::TYPE.as_bytes()[i];
79            assert!(
80                matches!(b, b'a'..=b'z' | b'_'),
81                "type prefix must contain only lowercase ascii, or underscores"
82            );
83            i += 1;
84        }
85        if !Self::TYPE.is_empty() {
86            assert!(
87                Self::TYPE.as_bytes()[0] != b'_',
88                "type prefix must not start with an underscore"
89            );
90            assert!(
91                Self::TYPE.as_bytes()[i - 1] != b'_',
92                "type prefix must not end with an underscore"
93            );
94        }
95        true
96    };
97}
98
99/// Represents a type that can serialize to and be parsed from a tag
100pub trait Type: Sized {
101    /// Try convert the prefix into the well known type.
102    /// If the prefix is incorrect, return the expected prefix.
103    fn try_from_type_prefix(tag: &str) -> Result<Self, Cow<'static, str>>;
104
105    /// Get the prefix from this type
106    fn to_type_prefix(&self) -> &str;
107}
108
109impl<T: StaticType> Type for T {
110    fn try_from_type_prefix(tag: &str) -> Result<Self, Cow<'static, str>> {
111        assert!(Self::__TYPE_PREFIX_IS_VALID);
112        if tag != Self::TYPE {
113            Err(Self::TYPE.into())
114        } else {
115            Ok(T::default())
116        }
117    }
118
119    fn to_type_prefix(&self) -> &str {
120        assert!(Self::__TYPE_PREFIX_IS_VALID);
121        Self::TYPE
122    }
123}
124
125/// A dynamic type prefix
126///
127/// ```
128/// use type_safe_id::{DynamicType, TypeSafeId};
129///
130/// let id: TypeSafeId<DynamicType> = "prefix_01h2xcejqtf2nbrexx3vqjhp41".parse().unwrap();
131///
132/// assert_eq!(id.type_prefix(), "prefix");
133/// assert_eq!(id.uuid(), uuid::uuid!("0188bac7-4afa-78aa-bc3b-bd1eef28d881"));
134/// ```
135#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
136pub struct DynamicType(ArrayString<63>);
137
138impl DynamicType {
139    /// Create a new type prefix from a dynamic str
140    ///
141    /// ```
142    /// use type_safe_id::{DynamicType, TypeSafeId};
143    ///
144    /// let dynamic_type = DynamicType::new("prefix").unwrap();
145    ///
146    /// let data = uuid::uuid!("0188bac7-4afa-78aa-bc3b-bd1eef28d881");
147    /// let id = TypeSafeId::from_type_and_uuid(dynamic_type, data);
148    ///
149    /// assert_eq!(id.to_string(), "prefix_01h2xcejqtf2nbrexx3vqjhp41");
150    /// ```
151    pub fn new(s: &str) -> Result<Self, Error> {
152        let tag: ArrayString<63> = s.try_into().map_err(|_| Error::InvalidType)?;
153
154        if tag.bytes().any(|b| !matches!(b, b'a'..=b'z' | b'_')) {
155            return Err(Error::InvalidType);
156        }
157        if !tag.is_empty() && (tag.as_bytes()[0] == b'_' || tag.as_bytes()[tag.len() - 1] == b'_') {
158            return Err(Error::InvalidType);
159        }
160        Ok(Self(tag))
161    }
162}
163
164impl Type for DynamicType {
165    fn try_from_type_prefix(tag: &str) -> Result<Self, Cow<'static, str>> {
166        let tag: ArrayString<63> = tag.try_into().map_err(|_| tag[..63].to_owned())?;
167        if tag.bytes().any(|b| !matches!(b, b'a'..=b'z' | b'_')) {
168            return Err(tag.to_lowercase().into());
169        }
170        if !tag.is_empty() && (tag.as_bytes()[0] == b'_' || tag.as_bytes()[tag.len() - 1] == b'_') {
171            return Err(tag.to_lowercase().into());
172        }
173        Ok(Self(tag))
174    }
175
176    fn to_type_prefix(&self) -> &str {
177        &self.0
178    }
179}
180
181/// A typed UUID.
182///
183/// ```
184/// use type_safe_id::{StaticType, TypeSafeId};
185///
186/// #[derive(Default)]
187/// struct User;
188///
189/// impl StaticType for User {
190///     // must be lowercase ascii [a-z] only
191///     const TYPE: &'static str = "user";
192/// }
193///
194/// // type alias for your custom typed id
195/// type UserId = TypeSafeId<User>;
196///
197/// let user_id1 = UserId::new();
198/// # std::thread::sleep(std::time::Duration::from_millis(10));
199/// let user_id2 = UserId::new();
200///
201/// let uid1 = user_id1.to_string();
202/// let uid2 = user_id2.to_string();
203/// dbg!(&uid1, &uid2);
204/// assert!(uid2 > uid1, "type safe IDs are ordered");
205///
206/// let user_id3: UserId = uid1.parse().expect("invalid user id");
207/// let user_id4: UserId = uid2.parse().expect("invalid user id");
208///
209/// assert_eq!(user_id1.uuid(), user_id3.uuid(), "round trip works");
210/// assert_eq!(user_id2.uuid(), user_id4.uuid(), "round trip works");
211/// ```
212#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
213pub struct TypeSafeId<T> {
214    tag: T,
215    data: Uuid,
216}
217
218impl<T: Type> fmt::Debug for TypeSafeId<T> {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        f.debug_struct("TypeSafeId")
221            .field("tag", &self.tag.to_type_prefix())
222            .field("data", &self.data)
223            .finish()
224    }
225}
226
227impl<T: Type> Hash for TypeSafeId<T> {
228    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
229        self.tag.to_type_prefix().hash(state);
230        self.data.hash(state);
231    }
232}
233
234struct Uuid128(u128);
235
236impl From<Uuid> for Uuid128 {
237    fn from(value: Uuid) -> Self {
238        Self(u128::from_be_bytes(value.into_bytes()))
239    }
240}
241impl From<Uuid128> for Uuid {
242    fn from(value: Uuid128) -> Self {
243        Uuid::from_bytes(value.0.to_be_bytes())
244    }
245}
246
247impl<T: StaticType> TypeSafeId<T> {
248    /// Create a new type-id
249    #[allow(clippy::new_without_default)]
250    pub fn new() -> Self {
251        Self::new_with_ts_rng(
252            T::default(),
253            uuid::Timestamp::now(NoContext),
254            &mut rand::rng(),
255        )
256    }
257
258    /// Create a new type-id from the given uuid data
259    pub fn from_uuid(data: Uuid) -> Self {
260        Self::from_type_and_uuid(T::default(), data)
261    }
262
263    /// The length of a type-id string with the given (static) type
264    pub const fn static_len() -> usize {
265        T::TYPE.len() + // Prefix length
266        1 + // `_` length
267        ENCODED_UUID_LEN
268    }
269}
270
271impl<T: StaticType> From<Uuid> for TypeSafeId<T> {
272    fn from(data: Uuid) -> Self {
273        Self::from_uuid(data)
274    }
275}
276
277impl<T: Type> TypeSafeId<T> {
278    /// Create a new type-id with the given type prefix
279    pub fn new_with_type(type_prefix: T) -> Self {
280        Self::new_with_ts_rng(
281            type_prefix,
282            uuid::Timestamp::now(NoContext),
283            &mut rand::rng(),
284        )
285    }
286
287    fn new_with_ts_rng(type_prefix: T, ts: uuid::Timestamp, rng: &mut impl rand::Rng) -> Self {
288        let (secs, nanos) = ts.to_unix();
289        let millis = (secs * 1000).saturating_add(nanos as u64 / 1_000_000);
290        Self::from_type_and_uuid(
291            type_prefix,
292            uuid::Builder::from_unix_timestamp_millis(millis, &rng.random()).into_uuid(),
293        )
294    }
295
296    /// Create a new type-id with the given type prefix and uuid data
297    pub fn from_type_and_uuid(type_prefix: T, data: Uuid) -> Self {
298        Self {
299            tag: type_prefix,
300            data,
301        }
302    }
303
304    pub fn type_prefix(&self) -> &str {
305        self.tag.to_type_prefix()
306    }
307
308    /// The length of a type-id string with the given type
309    ///
310    /// If your prefix is static, you can also use [`TypeSafeId::static_len`].
311    #[allow(clippy::len_without_is_empty)]
312    pub fn len(&self) -> usize {
313        self.type_prefix().len() +
314        1 + // `_` length
315        ENCODED_UUID_LEN
316    }
317
318    pub fn uuid(&self) -> Uuid {
319        self.data
320    }
321}
322
323impl<T: Type> FromStr for TypeSafeId<T> {
324    type Err = Error;
325
326    fn from_str(id: &str) -> Result<Self, Self::Err> {
327        let (tag, id) = match id.rsplit_once('_') {
328            Some(("", _)) => return Err(Error::InvalidType),
329            Some((tag, id)) => (tag, id),
330            None => ("", id),
331        };
332
333        let tag = T::try_from_type_prefix(tag).map_err(|expected| Error::IncorrectType {
334            actual: tag.into(),
335            expected,
336        })?;
337
338        Ok(Self {
339            tag,
340            data: parse_base32_uuid7(id)?.into(),
341        })
342    }
343}
344
345/// Encoded UUID length (see <https://github.com/jetify-com/typeid/tree/main/spec#uuid-suffix>)
346const ENCODED_UUID_LEN: usize = 26;
347
348fn parse_base32_uuid7(id: &str) -> Result<Uuid128, Error> {
349    let mut id: [u8; ENCODED_UUID_LEN] =
350        id.as_bytes().try_into().map_err(|_| Error::InvalidData)?;
351    let mut max = 0;
352    for b in &mut id {
353        *b = CROCKFORD_INV[*b as usize];
354        max |= *b;
355    }
356    if max > 32 || id[0] > 7 {
357        return Err(Error::InvalidData);
358    }
359
360    let mut out = 0u128;
361    for b in id {
362        out <<= 5;
363        out |= b as u128;
364    }
365
366    Ok(Uuid128(out))
367}
368
369impl<T: Type> fmt::Display for TypeSafeId<T> {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        f.write_str(&to_array_string(self.type_prefix(), self.data.into()))
372    }
373}
374
375fn to_array_string(prefix: &str, data: Uuid128) -> ArrayString<90> {
376    let mut out = ArrayString::new();
377
378    if !prefix.is_empty() {
379        out.push_str(prefix);
380        out.push_str("_");
381    }
382
383    let mut buf = [0; ENCODED_UUID_LEN];
384    let mut data = data.0;
385    for b in buf.iter_mut().rev() {
386        *b = CROCKFORD[((data as u8) & 0x1f) as usize];
387        debug_assert!(b.is_ascii());
388        data >>= 5;
389    }
390
391    let s = std::str::from_utf8(&buf).expect("only ascii bytes should be in the buffer");
392
393    out.push_str(s);
394    out
395}
396
397const CROCKFORD: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
398const CROCKFORD_INV: &[u8; 256] = &{
399    let mut output = [255; 256];
400
401    let mut i = 0;
402    while i < 32 {
403        output[CROCKFORD[i as usize] as usize] = i;
404        i += 1;
405    }
406
407    output
408};