Nutype 0.8.0 - attribute passthrough, serde customization and Decimal
Serhii Potapov September 20, 2026 #rust #macro #newtype #nutype #serdeI'm happy to announce the release of Nutype v0.8.0! The full release notes are on GitHub.
This release is mostly about one thing: opening up the generated type to the rest of the ecosystem. Until now a nutype newtype was a closed box. If you wanted to put #[repr(transparent)] on it, or let sqlx or garde see it, you were out of luck. That is fixed now.
What is Nutype?
Nutype is a Rust procedural macro that extends the newtype pattern with sanitization and validation. You define the rules, and Nutype generates a constructor that enforces them. There's no other way to create a value of that type, so a function that accepts a Username doesn't need to validate it again.
Attribute passthrough
Up to 0.7.0, any non-doc attribute written on the struct was a hard error, and any attribute written on the inner field was silently thrown away. Starting with 0.8.0 both are forwarded further to the generated type and its field:
#[nutype(derive(Debug, PartialEq))]
#[repr(transparent)]
struct Amount(i32);
It could work well along with derive_unchecked. Third-party trait derives now can be properly customized with their attributes. Field-level validation with garde:
#[nutype(
sanitize(trim),
derive(Debug, Clone),
derive_unchecked(garde::Validate),
)]
pub struct UserId(#[garde(length(min = 1))] String);
A transparent sqlx type:
#[nutype(
validate(not_empty),
derive(Debug, Clone),
derive_unchecked(sqlx::Type),
)]
#[sqlx(transparent)]
pub struct AccountId(String);
Or derive_more::Display picking up a type-level attribute:
#[nutype(derive(Debug), derive_unchecked(derive_more::Display))]
#[display("ID-{_0}")]
struct Id(u32);
assert_eq!(Id::new(7).to_string(), "ID-7");
There are three exceptions:
#[derive(...)]still has to go through#[nutype(derive(...))], because nutype needs to know which traits it generates itself.#[serde(...)]is consumed by nutype instead of being forwarded. More on that below.#[cfg(...)]on the inner field is rejected explicitly. On the item itself it works fine without any help from nutype, since rustc strips it before the macro even runs.
The same warning as for derive_unchecked applies here. Nutype forwards attributes as they are and has no idea what they do. An attribute macro that rewrites your type can break the guarantees nutype gives you.
Serde customization
Nutype generates its own Serialize and Deserialize implementations, which used to mean your #[serde(...)] attributes had nowhere to go. In 0.8.0 they are understood natively (#201): field-level with, serialize_with and deserialize_with, and type-level transparent.
The part I care about most: sanitization and validation still run on deserialization, even when a custom deserialize_with function did the decoding.
// `hex_bytes` is a plain serde codec module with `serialize` / `deserialize`.
#[nutype(
validate(predicate = |v| !v.is_empty()),
derive(Debug, PartialEq, Serialize, Deserialize),
)]
struct Token(#[serde(with = "hex_bytes")] Vec<u8>);
let token = Token::try_new(vec![0xde, 0xad]).unwrap();
assert_eq!(serde_json::to_string(&token).unwrap(), "\"dead\"");
// "" decodes to an empty Vec, which the predicate rejects.
assert!(serde_json::from_str::<Token>("\"\"").is_err());
Sanitizers run on the decoded value too:
#[nutype(
sanitize(with = |mut v| { v.sort(); v }),
derive(Debug, PartialEq, Serialize, Deserialize),
)]
struct SortedBytes(#[serde(with = "hex_bytes")] Vec<u8>);
let value: SortedBytes = serde_json::from_str("\"0201\"").unwrap();
assert_eq!(value.into_inner(), vec![0x01, 0x02]);
And #[serde(transparent)] makes the newtype serialize as exactly the inner value would, in any format, while validation still guards the way in:
#[nutype(validate(greater_or_equal = 0), derive(Debug, Serialize, Deserialize))]
#[serde(transparent)]
struct Positive(i32);
assert!(serde_json::from_str::<Positive>("-1").is_err());Decimal as an inner type
Money and percentages are a textbook case for newtypes, and floats are a textbook wrong way to store them. Nutype 0.8.0 supports rust_decimal::Decimal as a first class inner type (#242), with the same validators and sanitizers as the other numeric types.
It sits behind a feature flag, and you have to bring rust_decimal in yourself:
nutype = { version = "0.8", features = ["rust_decimal"] }
rust_decimal = "1"use nutype::nutype;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
#[nutype(
validate(greater_or_equal = 0, less_or_equal = 100),
derive(Debug, Clone, Copy, PartialEq, PartialOrd, Display),
)]
struct Percentage(Decimal);
#[nutype(
sanitize(with = |d: Decimal| d.round_dp(2)),
validate(greater_or_equal = 0),
derive(Debug, Clone, Copy, PartialEq),
)]
struct Money(Decimal);
assert_eq!(
Percentage::try_new(dec!(150)),
Err(PercentageError::LessOrEqualViolated)
);
assert_eq!(Money::try_new(dec!(9.999)).unwrap().into_inner(), dec!(10.00));
Note that bounds and values are written differently. Bounds in the attribute are plain literals (0, 100), because they are parsed at compile time through rust_decimal's FromStr. Values you pass to try_new() are real Decimals, so there you usually reach for dec!(...) or Decimal::from(...). All three spellings of the inner type are recognized: Decimal, rust_decimal::Decimal and ::rust_decimal::Decimal.
A complete example lives in examples/decimal_percentage.
Better error messages
Two changes that make typos and half-written attributes easier to deal with.
A mistyped attribute now suggests the closest match and tells you what else is available (#240):
error: Unknown nutype attribute `validte`. Did you mean `validate`?
Other available nutype attributes are: `sanitize`, `derive`, `default`, `const_fn`, `cfg_attr`, `constructor`.error: Unknown validator `lenCharMax`. Did you mean `len_char_max`?
The second change is harder to notice. When the arguments of #[nutype(...)] fail to parse,
which is not unusual when a code is being typed, the macro now emits a best-effort type skeleton next to the error (#178).
That means that newtype still stays resolvable. This aims to reduce number of irrelevant errors produced by rust-analyzer.
Smaller things
Macro expansion is reproducible now. Traits used to be collected into a HashSet, and its iteration order varies between runs, so the order of derives and generated impls in the emitted code was not stable.
Now BTreeSet is used now which guarantees deterministic macro expansion.
The generated new, try_new and new_unchecked are marked #[inline], matching into_inner, so they can be inlined across crate boundaries (#237).
The Display text of float validation errors for less and less_or_equal was swapped. less said "must be less or equal to" and vice versa. Integers and decimals were always correct. Embarrassing, but fixed.
Under the hood, integers, floats and decimals now share most of their code. Without that refactoring, adding Decimal would have been a lot more work. The generated code and the public API have not changed.