Support Ukraine. DONATE.
A blog about software development.

Nutype 0.8.0 - attribute passthrough, serde customization and Decimal

Serhii Potapov September 20, 2026 #rust #macro #newtype #nutype #serde

I'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 declare the rules once, and the generated constructor becomes the only gate into the type. Once a value is inside, it carries its invariants with it, so a function that takes a Username can trust that it got a valid username.

Attribute passthrough

This was the most requested missing piece (#228, #229). Up to 0.7.0, any non-doc attribute you wrote on the struct was a hard error, and any attribute you wrote on the inner field was silently thrown away. Silently, which is the worse half of that sentence.

Starting with 0.8.0 both are forwarded verbatim onto the generated type and its field:

#[nutype(derive(Debug, PartialEq))]
#[repr(transparent)]
struct Amount(i32);

The interesting part is what this unlocks in combination with derive_unchecked. Third-party derives read attributes, and now they can actually find them. 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:

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. Your wire format changes, your invariants do not.

// `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\"");

// An empty hex string decodes just fine, but the predicate still rejects it.
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));

There is an asymmetry worth pointing out. Bounds in the attribute are written as bare 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 errors while you type

Two fixes aimed at the moment when you are writing the macro, not the moment when it finally compiles.

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 one is subtler. When the arguments of #[nutype(...)] fail to parse, which is the normal state of affairs while you are halfway through typing them, the macro now emits a best-effort type skeleton next to the error (#178). The newtype stays resolvable, so rust-analyzer keeps working on the rest of the file instead of giving up and painting everything red.

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. They live in a BTreeSet now. This matters if you diff cargo expand output or care about reproducible builds.

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.

Internally the integer, float and decimal backends were consolidated onto a shared numeric layer, which is what made the decimal support reasonable to add in the first place. No change to the generated code or the public API.

Back to top