|
2 | 2 |
|
3 | 3 | Lightweight, macro-free newtypes with refinement and derived traits. |
4 | 4 |
|
| 5 | +## Motivation |
| 6 | + |
| 7 | +Newtypes are a fundamental pattern in Rust for enhancing type safety and expressing semantic meaning. By wrapping an existing type (the `Rep`resentation) in a new, distinct type, we prevent accidental misuse and clearly communicate intent. For example, distinguishing between a `UserId` and a `ProductId`, even if both are internally represented as `u64`, prevents bugs where one might be used in place of the other. This style makes code more self-documenting and less prone to logical errors. |
| 8 | + |
| 9 | +While standard traits like `Eq`, `PartialEq`, `Ord`, `PartialOrd`, `Clone`, and `Debug` can often be derived automatically in Rust, many essential traits are not. These include parsing (`FromStr`), serialization (`serde::Serialize`/`Deserialize`), and database integration (`diesel::Queryable`, `ToSql`, `FromSql`, `AsExpression`). Implementing these traits for newtypes manually can lead to substantial boilerplate. |
| 10 | + |
| 11 | +Rust has common macro-based solutions for the newtype traits derivation problem. Macros are often employed as a last resort when language expressiveness is insufficient. However, they introduce significant drawbacks: |
| 12 | + |
| 13 | +- Syntax: Macro-based code is not just Rust code. It acts as a complex, "foreign" DSL that is hard to read, maintain, and extend. |
| 14 | +- Boilerplate: Despite their intent, macros frequently require significant boilerplate, undermining their primary benefit. |
| 15 | +- Complexity: Macros can obscure the underlying logic and make debugging difficult. |
| 16 | + |
| 17 | +`functora-tagged` offers a superior, macro-free alternative. It provides a clean, idiomatic, and type-safe mechanism for creating newtypes. Through the `Refine` trait, you can define custom validation and transformation logic for your newtype. This logic is then automatically integrated into implementations for crucial, non-trivially derivable traits like `FromStr`, `serde`, and `diesel`, achieving true zero boilerplate for these complex scenarios without the downsides of macros. |
| 18 | + |
| 19 | +## Tagged Struct |
| 20 | + |
| 21 | +The primary newtype building block is the `Tagged<Rep, Tag>` struct. |
| 22 | + |
| 23 | +- `Rep`: The underlying representation type (e.g., `String`, `i32`). |
| 24 | +- `Tag`: A phantom type used at compile time to distinguish between different newtypes that share the same `Rep`. The `Tag` type itself implements the `Refine<Rep>` trait to define refinement logic. |
| 25 | + |
| 26 | +This structure allows you to create distinct types that behave identically to their `Rep` type for many traits, unless explicitly customized. |
| 27 | + |
| 28 | +```rust |
| 29 | +use std::marker::PhantomData; |
| 30 | + |
| 31 | +pub struct Tagged<Rep, Tag>(Rep, PhantomData<Tag>); |
| 32 | +``` |
| 33 | + |
| 34 | +## Refine Trait |
| 35 | + |
| 36 | +To enforce specific refinement rules for your newtypes, you implement the `Refine<Rep>` trait for the `Tag` type. This trait allows you to define custom logic for refining the newtype representation. |
| 37 | + |
| 38 | +```rust |
| 39 | +use functora_tagged::*; |
| 40 | + |
| 41 | +pub enum NonEmptyTag {} |
| 42 | +pub struct NonEmptyError; |
| 43 | + |
| 44 | +impl Refine<String> for NonEmptyTag { |
| 45 | + type RefineError = NonEmptyError; |
| 46 | + fn refine( |
| 47 | + rep: String, |
| 48 | + ) -> Result<String, Self::RefineError> { |
| 49 | + if rep.is_empty() { |
| 50 | + Err(NonEmptyError) |
| 51 | + } else { |
| 52 | + Ok(rep) |
| 53 | + } |
| 54 | + } |
| 55 | +} |
| 56 | +``` |
| 57 | + |
| 58 | +## Derived Traits |
| 59 | + |
| 60 | +`functora-tagged` provides blanket implementations for several important traits. These traits work seamlessly with your newtypes, respecting the underlying representation behavior and customizable refinement rules defined by the `Tag` type's implementation of `Refine<Rep>`. |
| 61 | + |
| 62 | +### Direct Derive: |
| 63 | + |
| 64 | +- Eq |
| 65 | +- PartialEq |
| 66 | +- Ord |
| 67 | +- PartialOrd |
| 68 | +- Clone |
| 69 | +- Debug |
| 70 | +- serde::Serialize (with `serde` feature) |
| 71 | +- diesel::serialize::ToSql (with `diesel` feature) |
| 72 | +- diesel::expression::AsExpression (with `diesel` feature) |
| 73 | + |
| 74 | +### Refined Derive: |
| 75 | + |
| 76 | +- FromStr |
| 77 | +- serde::Deserialize (with `serde` feature) |
| 78 | +- diesel::Queryable (with `diesel` feature) |
| 79 | +- diesel::deserialize::FromSql (with `diesel` feature) |
| 80 | + |
| 81 | +## Examples |
| 82 | + |
| 83 | +You can promote `Rep` values into newtype values using `Tagged::new(rep)` applied directly to a `Rep` value. To demote a newtype value back to a `Rep` value, you can use the `.rep()` method. You can also use any serializer or deserializer for the newtype that is available for `Rep`. |
| 84 | + |
| 85 | +### Default Newtype |
| 86 | + |
| 87 | +When a `Tag` type has a default `Refine` implementation that doesn't add new constraints or transformations, `Tagged` can be used for simple type distinction. |
| 88 | + |
| 89 | +```rust |
| 90 | +use functora_tagged::*; |
| 91 | + |
| 92 | +pub enum NonNegTag {} |
| 93 | + |
| 94 | +impl Refine<usize> for NonNegTag { |
| 95 | + type RefineError = (); |
| 96 | +} |
| 97 | + |
| 98 | +pub type NonNeg = Tagged<usize, NonNegTag>; |
| 99 | + |
| 100 | +let rep = 123; |
| 101 | +let new = NonNeg::new(rep).unwrap(); |
| 102 | + |
| 103 | +assert_eq!(*new.rep(), rep); |
| 104 | +``` |
| 105 | + |
| 106 | +### Refined Newtype |
| 107 | + |
| 108 | +This example demonstrates a simple refinement for numeric types to ensure they are positive, using `PositiveTag`. |
| 109 | + |
| 110 | +```rust |
| 111 | +use functora_tagged::*; |
| 112 | + |
| 113 | +#[derive(PartialEq, Debug)] |
| 114 | +pub enum PositiveTag {} |
| 115 | +pub type Positive = Tagged<usize, PositiveTag>; |
| 116 | + |
| 117 | +#[derive(PartialEq, Debug)] |
| 118 | +pub struct PositiveError; |
| 119 | + |
| 120 | +impl Refine<usize> for PositiveTag { |
| 121 | + type RefineError = PositiveError; |
| 122 | + fn refine( |
| 123 | + rep: usize, |
| 124 | + ) -> Result<usize, Self::RefineError> |
| 125 | + { |
| 126 | + if rep > 0 { |
| 127 | + Ok(rep) |
| 128 | + } else { |
| 129 | + Err(PositiveError) |
| 130 | + } |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +let rep = 100; |
| 135 | +let new = Positive::new(rep).unwrap(); |
| 136 | +assert_eq!(*new.rep(), rep); |
| 137 | + |
| 138 | +let err = Positive::new(0).unwrap_err(); |
| 139 | +assert_eq!(err, PositiveError); |
| 140 | +``` |
| 141 | + |
| 142 | +### Generic Newtype |
| 143 | + |
| 144 | +This demonstrates a generic `Positive<Rep>` newtype that enforces positive values for any numeric type `Rep` that implements `Refine<PositiveTag>`. |
| 145 | + |
| 146 | +```rust |
| 147 | +use functora_tagged::*; |
| 148 | +use num_traits::Zero; |
| 149 | + |
| 150 | +#[derive(PartialEq, Debug)] |
| 151 | +pub enum PositiveTag {} |
| 152 | +pub type Positive<Rep> = Tagged<Rep, PositiveTag>; |
| 153 | + |
| 154 | +#[derive(PartialEq, Debug)] |
| 155 | +pub struct PositiveError; |
| 156 | + |
| 157 | +impl<Rep> Refine<Rep> for PositiveTag |
| 158 | +where |
| 159 | + Rep: Zero + PartialOrd, |
| 160 | +{ |
| 161 | + type RefineError = PositiveError; |
| 162 | + fn refine( |
| 163 | + rep: Rep, |
| 164 | + ) -> Result<Rep, Self::RefineError> |
| 165 | + { |
| 166 | + if rep > Rep::zero() { |
| 167 | + Ok(rep) |
| 168 | + } else { |
| 169 | + Err(PositiveError) |
| 170 | + } |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +let rep = 100; |
| 175 | +let new = Positive::<i32>::new(rep).unwrap(); |
| 176 | +assert_eq!(*new.rep(), rep); |
| 177 | + |
| 178 | +let rep = 10.5; |
| 179 | +let new = Positive::<f64>::new(rep).unwrap(); |
| 180 | +assert_eq!(*new.rep(), rep); |
| 181 | + |
| 182 | +let err = Positive::<i32>::new(-5).unwrap_err(); |
| 183 | +assert_eq!(err, PositiveError); |
| 184 | + |
| 185 | +let err = Positive::<f64>::new(0.0).unwrap_err(); |
| 186 | +assert_eq!(err, PositiveError); |
| 187 | +``` |
| 188 | + |
| 189 | +### Nested Newtype |
| 190 | + |
| 191 | +This example demonstrates nesting newtypes: `UserId<Rep>` generic newtype is built on top of the other `NonEmpty<Rep>` generic newtype and adds its own refinement logic. |
| 192 | + |
| 193 | +```rust |
| 194 | +use functora_tagged::*; |
| 195 | + |
| 196 | +#[derive(PartialEq, Debug)] |
| 197 | +pub enum NonEmptyTag {} |
| 198 | +pub type NonEmpty<Rep> = Tagged<Rep, NonEmptyTag>; |
| 199 | + |
| 200 | +#[derive(PartialEq, Debug)] |
| 201 | +pub struct NonEmptyError; |
| 202 | + |
| 203 | +impl Refine<String> for NonEmptyTag { |
| 204 | + type RefineError = NonEmptyError; |
| 205 | + fn refine( |
| 206 | + rep: String, |
| 207 | + ) -> Result<String, Self::RefineError> |
| 208 | + { |
| 209 | + if rep.is_empty() { |
| 210 | + Err(NonEmptyError) |
| 211 | + } else { |
| 212 | + Ok(rep) |
| 213 | + } |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +impl Refine<isize> for NonEmptyTag { |
| 218 | + type RefineError = NonEmptyError; |
| 219 | + fn refine( |
| 220 | + rep: isize, |
| 221 | + ) -> Result<isize, Self::RefineError> |
| 222 | + { |
| 223 | + if rep == 0 { |
| 224 | + Err(NonEmptyError) |
| 225 | + } else { |
| 226 | + Ok(rep) |
| 227 | + } |
| 228 | + } |
| 229 | +} |
| 230 | + |
| 231 | +#[derive(PartialEq, Debug)] |
| 232 | +pub enum UserIdTag {} |
| 233 | +pub type UserId<Rep> = |
| 234 | + Tagged<NonEmpty<Rep>, UserIdTag>; |
| 235 | + |
| 236 | +#[derive(PartialEq, Debug)] |
| 237 | +pub struct UserIdError; |
| 238 | + |
| 239 | +impl Refine<NonEmpty<String>> for UserIdTag { |
| 240 | + type RefineError = UserIdError; |
| 241 | + fn refine( |
| 242 | + rep: NonEmpty<String>, |
| 243 | + ) -> Result<NonEmpty<String>, Self::RefineError> |
| 244 | + { |
| 245 | + if rep.rep().starts_with("user_") |
| 246 | + && rep.rep().len() > 5 |
| 247 | + { |
| 248 | + Ok(rep) |
| 249 | + } else { |
| 250 | + Err(UserIdError) |
| 251 | + } |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +impl Refine<NonEmpty<isize>> for UserIdTag { |
| 256 | + type RefineError = UserIdError; |
| 257 | + fn refine( |
| 258 | + rep: NonEmpty<isize>, |
| 259 | + ) -> Result<NonEmpty<isize>, Self::RefineError> |
| 260 | + { |
| 261 | + if *rep.rep() > 0 { |
| 262 | + Ok(rep) |
| 263 | + } else { |
| 264 | + Err(UserIdError) |
| 265 | + } |
| 266 | + } |
| 267 | +} |
| 268 | + |
| 269 | +let rep = "user_123"; |
| 270 | +let new = rep.parse::<UserId<String>>().unwrap(); |
| 271 | +assert_eq!(new.rep().rep(), rep); |
| 272 | + |
| 273 | +let err = "".parse::<UserId<String>>().unwrap_err(); |
| 274 | +assert_eq!( |
| 275 | + err, |
| 276 | + ParseError::Decode(ParseError::Refine( |
| 277 | + NonEmptyError |
| 278 | + )) |
| 279 | +); |
| 280 | + |
| 281 | +let err = "post_123" |
| 282 | + .parse::<UserId<String>>() |
| 283 | + .unwrap_err(); |
| 284 | +assert_eq!(err, ParseError::Refine(UserIdError)); |
| 285 | + |
| 286 | +let rep: isize = 123; |
| 287 | +let new = rep |
| 288 | + .to_string() |
| 289 | + .parse::<UserId<isize>>() |
| 290 | + .unwrap(); |
| 291 | +assert_eq!(*new.rep().rep(), rep); |
| 292 | + |
| 293 | +let err = "0".parse::<UserId<isize>>().unwrap_err(); |
| 294 | +assert_eq!( |
| 295 | + err, |
| 296 | + ParseError::Decode(ParseError::Refine( |
| 297 | + NonEmptyError |
| 298 | + )) |
| 299 | +); |
| 300 | + |
| 301 | +let err = "-1".parse::<UserId<isize>>().unwrap_err(); |
| 302 | +assert_eq!(err, ParseError::Refine(UserIdError)); |
| 303 | +``` |
| 304 | + |
| 305 | +## Integrations |
| 306 | + |
| 307 | +`functora-tagged` provides optional integrations for common Rust ecosystems: |
| 308 | + |
| 309 | +- **`serde`**: For serialization and deserialization. Enable with the `serde` feature. |
| 310 | +- **`diesel`**: For database interactions. Enable with the `diesel` feature. |
| 311 | + |
| 312 | +These integrations respect the `Refine` rules defined for your types. |
| 313 | + |
5 | 314 | <hr> |
6 | 315 |
|
7 | 316 | © 2025 [Functora](https://functora.github.io/). All rights reserved. |
0 commit comments