Smart Constructors in Rust
In this post
- A newtype gives a value a distinct meaning
- Keep construction inside a module
- Choose a fallible construction API
- Preserve the invariant during mutation
- Validate relationships between fields
- Treat deserialization as construction
- Constants, layout, and unchecked construction
- When a simpler type is better
A function that accepts u64 accepts every possible u64, even when the domain allows only positive quantities. The caller may pass zero, and the function has no way to tell from its signature whether zero is meaningful. The earlier post on modeling data with Rust’s type system introduced newtypes and type-driven modeling; this post focuses on the constructor boundary that keeps a value valid after it enters the domain model.
One response is to add a check at the start of every function that uses the value. That protects each function only while the check remains there. Another function may forget it, and a mutable public field may make the value invalid again after the first check.
A smart constructor moves this rule to the place where the domain value is created. The constructor checks the raw value and returns a type whose representation is private. Code that receives the type can then use the rule without repeating the same test.
An invariantIn mathematics, an invariant is a property of a mathematical object which remains unchanged after operations or transformations of a certain type are applied to the objects. The particular class of objects and type of transformations are usually indicated by the context in which the term is used. For example, the area of a triangle is an invariant with respect to isometries of the Euclidean plane. The phrases "invariant under" and "invariant to" a transformation are both used. More generally, an invariant with respect to an equivalence relation is a property that is constant on each equivalence class.Read on Wikipedia → is a rule that must hold for every valid value of a type. For an order quantity in this example, the invariant is that the value is between 1 and 1,000,000.
For an arbitrary numeric predicate, the constructor still checks the value at runtime. After that check succeeds, the compiler can enforce the type boundary: ordinary public code cannot rebuild or change the value without using another checked operation.
A newtype gives a value a distinct meaning
A small wrapper around a value is called a newtypeNewtype is a monthly magazine originating from Japan covering anime and, to a lesser extent, manga, voice actors, science fiction, tokusatsu, and video games. It was launched by publishing company Kadokawa Shoten on March 8, 1985, and has since been released in Japan on the 10th of every month.Read on Wikipedia →. It creates a different Rust type, so the compiler can stop unrelated values from being mixed:
pub struct OrderQuantity(pub u64);
pub struct AccountId(pub u64);
fn reserve(quantity: OrderQuantity) {
// ...
}
let account = AccountId(42);
// reserve(account); // does not compile
This is safer than using u64 for both arguments. However, the public field still allows every possible quantity:
let invalid = OrderQuantity(0);
The newtype tells Rust what the number means. Range validation remains a separate responsibility. The same problem appears when the wrapped field is private but the module exposes a constructor that does not check its input.
To enforce a value rule, the type needs two parts:
- a private representation that callers cannot construct directly;
- a public constructor that checks the rule before returning the value.
The following example defines an order quantity as a number from 1 to 1,000,000. The upper limit is an example domain decision. A real application should choose a limit that belongs to its own business rules. The section “Constants, layout, and unchecked construction” explains the const fn, #[repr(transparent)], and unsafe fn new_unchecked used in the listing.
use std::fmt;
use std::num::NonZeroU64;
use std::str::FromStr;
mod order_quantity {
use super::*;
const MAX: u64 = 1_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct OrderQuantity(NonZeroU64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuantityError {
NotANumber,
Zero,
TooLarge,
}
impl OrderQuantity {
const fn check(value: u64) -> Result<NonZeroU64, QuantityError> {
match NonZeroU64::new(value) {
None => Err(QuantityError::Zero),
Some(value) if value.get() > MAX => Err(QuantityError::TooLarge),
Some(value) => Ok(value),
}
}
pub const fn new(value: u64) -> Option<Self> {
match Self::check(value) {
Ok(value) => Some(Self(value)),
Err(_) => None,
}
}
pub fn try_new(value: u64) -> Result<Self, QuantityError> {
Self::check(value).map(Self)
}
pub fn get(self) -> u64 {
self.0.get()
}
/// Creates a quantity without checking its value.
///
/// # Safety
/// `value` must be in the range 1..=1_000_000, matching `MAX`.
pub unsafe fn new_unchecked(value: u64) -> Self {
Self(unsafe { NonZeroU64::new_unchecked(value) })
}
}
impl TryFrom<u64> for OrderQuantity {
type Error = QuantityError;
fn try_from(value: u64) -> Result<Self, Self::Error> {
Self::try_new(value)
}
}
impl FromStr for OrderQuantity {
type Err = QuantityError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let value = text
.parse::<u64>()
.map_err(|_| QuantityError::NotANumber)?;
Self::try_from(value)
}
}
impl fmt::Display for QuantityError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::NotANumber => "quantity is not a number",
Self::Zero => "quantity must be greater than zero",
Self::TooLarge => "quantity is too large",
};
formatter.write_str(message)
}
}
}
use order_quantity::{OrderQuantity, QuantityError};
OrderQuantity is public, so it can appear in the signatures of public functions. Its field is private, so code outside the order_quantity module cannot write OrderQuantity(raw).
NonZeroU64 already represents one part of the rule. Its new function returns None for zero, and a valid NonZeroU64 can never contain zero. The quantity still needs its own upper-limit check, because NonZeroU64 does not know anything about the application’s maximum.
The type now gives the caller a useful parsing boundary:
fn parse_quantity(text: &str) -> Result<OrderQuantity, QuantityError> {
text.parse()
}
The parsing operation can fail. After it succeeds, any function that receives the OrderQuantity gets a value that passed both checks. It does not need to repeat quantity == 0 or quantity > 1_000_000.
Keep construction inside a module
Rust privacy is defined for modules. A private item can be used by the module that defines it and by its descendants. It is not limited to one impl block, and it is not always limited to one file.
That means this code inside the defining module can still bypass the constructor:
mod order_quantity {
pub struct OrderQuantity(u64);
fn internal_value() -> OrderQuantity {
OrderQuantity(0)
}
}
A sibling module cannot use the private field, but a child module of order_quantity can. The trusted module should therefore be small enough to audit. Keep the representation, constructors, checked mutation methods, and conversion implementations together. Re-export the public type if the crate wants callers to use a shorter path:
pub use order_quantity::{OrderQuantity, QuantityError};
This boundary protects ordinary safe callers while the trusted module and its adapters remain correct. The defining module can contain a bug, and unsafe code, invalid data received through a foreign function interface (FFI)A foreign function interface (FFI) is a mechanism by which a program written in one programming language can call interoperable routines or make use of services written or compiled in another one. An FFI is often used in contexts where calls are made into a binary dynamic-link library.Read on Wikipedia →, or unchecked framework adapters can still bypass the intended path.
The distinction is important: a private field prevents external struct literals, but it does not automatically validate data arriving from a file, a database, or a network message. Those are construction paths too.
Choose a fallible construction API
A constructor is smart when it refuses a value that does not satisfy the type’s invariant. Rust does not have a special keyword for this pattern. The usual choices are ordinary methods and standard conversion traits.
Use Option<Self> when the only useful result is “a valid value” or “no value”:
let quantity = OrderQuantity::new(raw_value);
Here None represents either zero or a value above the configured maximum. Use Result<Self, Error> when the caller needs to know why construction failed:
let quantity = OrderQuantity::try_new(raw_value)?;
The error can distinguish zero from a value above the limit. That is useful for an API response, a log message, or a test. Result also makes the failure part of the function’s contract instead of hiding it in a panic.
TryFrom is a natural interface when the operation converts a raw representation into a domain type:
let quantity = OrderQuantity::try_from(250_u64)?;
FromStr provides the same boundary for text and lets callers use .parse(). All construction paths — new, try_new, TryFrom, and FromStr — end in the private check function, so the rule has one owner. If the constructor and the trait implementation each contain their own checks, they can disagree after a later change.
The name try_new is a useful convention because it warns the reader that construction can fail. new(...) -> Result<Self, Error> is also common. The return type and the single validation owner matter more than the method name.
Preserve the invariant during mutation
A constructor checks the value only at the moment of construction. The invariant must also survive the rest of the value’s lifetime.
A getter is safe because it gives the caller a copy or a shared reference:
let raw_value = quantity.get();
A public mutable reference is different. A method like this would compile inside the defining module, but it would let a caller replace a valid quantity with another value without checking the upper limit:
// This public method would compile inside the defining module,
// but it should not be part of the public API.
impl OrderQuantity {
pub fn as_mut(&mut self) -> &mut NonZeroU64 {
&mut self.0
}
}
The same problem can appear through AsMut or DerefMut, traits that can provide mutable access to the inner value. A caller should not be able to modify that value without passing through validation. In the example above, NonZeroU64 prevents zero, but it does not prevent a caller from writing 5_000_001, which violates OrderQuantity’s separate upper limit.
If mutation is useful, provide a checked operation. Validate the replacement before assigning it:
impl OrderQuantity {
pub fn try_set(&mut self, value: u64) -> Result<(), QuantityError> {
let next = Self::try_new(value)?;
*self = next;
Ok(())
}
}
When construction fails, next is never created and the old quantity remains unchanged. This is easier to reason about than temporarily storing an invalid value and trying to repair it later.
Read-only Deref is not forbidden in every wrapper. It can be appropriate when the wrapper transparently behaves like its target and deref coercion will not surprise callers. For a domain value, an explicit method such as get usually keeps the conversion clearer and leaves more freedom to change the internal representation later.
Validate relationships between fields
A newtype can check a rule about one value. It cannot check a relationship between two separate values unless both values are given to the same constructor.
Suppose PriceCents is already a validated price type that implements PartialOrd. Two valid prices can still form an invalid range if the lower price is greater than the upper price:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangeError {
Reversed,
}
impl std::fmt::Display for RangeError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Reversed => formatter.write_str("range bounds are reversed"),
}
}
}
pub struct PriceRange {
low: PriceCents,
high: PriceCents,
}
impl PriceRange {
pub fn try_new(low: PriceCents, high: PriceCents)
-> Result<Self, RangeError>
{
if low > high {
return Err(RangeError::Reversed);
}
Ok(Self { low, high })
}
}
The fields are private because exposing either one for unrestricted mutation could break the relationship. A method such as set_high should construct a new valid range or return an error. The parent type owns the comparison; PriceCents owns the rules for one price. Here PriceCents must also implement PartialOrd so the comparison is available.
This separation also works for time ranges, non-crossed quotes, and configuration values whose fields must agree. A type should enforce the rule at the narrowest boundary that has enough information to check it.
Treat deserialization as construction
Deserialization converts an external representation into a Rust value. For an invariant-bearing type, it is another constructor and must use the same validation path.
A direct Deserialize derive can populate a private field without calling try_new. The generated code has access to the type’s representation, so privacy alone does not route input through the public constructor:
// This form can bypass the domain checks.
#[derive(serde::Deserialize)]
pub struct OrderQuantity(u64);
Serde provides a try_from container attribute for this case. Deserialize the raw representation first, then convert it through TryFrom:
// Add `serde::Deserialize` to the derive list and this attribute
// to the existing definition.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)]
#[repr(transparent)]
#[serde(try_from = "u64")]
pub struct OrderQuantity(std::num::NonZeroU64);
// Reuse the `TryFrom<u64>` implementation shown earlier.
With #[serde(try_from = "u64")], Serde reads a u64 and then calls TryFrom<u64>. A zero or an excessive quantity becomes a deserialization error instead of an invalid domain value. The error type must implement Display, which is why the earlier example defined it.
A composite type needs a raw representation containing all fields. Its TryFrom implementation can validate each field and then validate the relationship between them:
#[derive(serde::Deserialize)]
struct RawPriceRange {
low: u64,
high: u64,
}
#[derive(serde::Deserialize)]
#[serde(try_from = "RawPriceRange")]
pub struct PriceRange {
low: PriceCents,
high: PriceCents,
}
The TryFrom<RawPriceRange> implementation must convert both raw prices, check their order, and return an error that implements Display. The complete implementation is longer because it keeps the invariant at the boundary rather than distributing it across every reader.
The same review is needed for database conversions, configuration loaders, binary decoders, FFI boundaries, and Default. A wrapper around plain u64 that derives Default would produce zero, which is invalid for this quantity. Do not add Default merely to make an API more convenient.
Constants, layout, and unchecked construction
A constructor can be const when it is useful to validate fixed configuration values during compilation. The OrderQuantity definition above includes a const fn new that returns Option<Self>:
pub const DEFAULT_BATCH: OrderQuantity = match OrderQuantity::new(100) {
Some(value) => value,
None => panic!("invalid batch size"),
};
Because this call appears in a constant context, the compiler evaluates it while compiling the program. Changing 100 to 0 makes the constant fail to compile. A call to the same const fn in an ordinary runtime function is still an ordinary runtime call; const fn does not force every call to happen during compilation.
The #[repr(transparent)] attribute in the earlier example has a different purpose. It guarantees that the wrapper has the same layout and application binary interfaceAn application binary interface (ABI) is an interface exposed by software that is defined for in-process machine code access. Often, the exposing software is a library, and the consumer is a program.Read on Wikipedia → as its one non-zero-sized field. An FFI boundary may need that guarantee. The attribute does not validate values and does not make an arbitrary foreign function safe to call.
The representation can also matter for Option:
use std::mem::size_of;
assert_eq!(
size_of::<Option<OrderQuantity>>(),
size_of::<u64>(),
);
This compact representation is documented because OrderQuantity is transparent over NonZeroU64. The zero bit pattern can represent None, while every valid NonZeroU64 represents Some. Do not generalise this result to every wrapper. A wrapper around a plain u64 does not automatically gain a compact Option representation just because its constructor rejects zero.
An unchecked constructor is another possible construction path, but it should not be the normal path. The method in the main definition is marked unsafe and uses NonZeroU64::new_unchecked only after the caller accepts its documented precondition.
The safety requirement has two parts. Zero would violate NonZeroU64’s validity rules and cause undefined behaviorA computer program exhibits undefined behavior (UB) when it contains, or is executing code for which its programming language specification does not mandate any specific requirements. This is different from unspecified behavior, for which the language specification does not prescribe a result, and implementation-defined behavior that defers to the documentation of another component of the platform.Read on Wikipedia →, meaning that Rust can no longer rely on its memory-safety guarantees. A value above the application limit would keep the representation valid but break OrderQuantity’s documented domain invariant. The caller must already have checked both conditions before using this function.
The safe constructor should remain the default. An unchecked constructor is appropriate only when a measured boundary has already established the precondition and the extra API is worth the safety burden. It is not a general replacement for a normal checked constructor.
When a simpler type is better
A smart constructor adds a type name, an error type, conversion methods, and a module boundary. That is useful when a wrong value can reach many parts of the program or cause a realistic failure. It is unnecessary for every local variable.
A standard type is often the simplest correct choice. Use NonZeroU64 directly when the only rule is that a number cannot be zero. Add a domain-specific wrapper when the name matters, when the type has more rules, or when the API should distinguish an order quantity from another non-zero number.
Do not encode a changing external fact as a permanent value invariant. A constructor can check whether an account exists in a database at one moment, but the account may later be disabled or deleted. Keep that check on the operation that needs it instead of claiming that an AccountId proves current existence.
The same restraint applies to typestate, the pattern of using different types for different lifecycle states, and to deeply generic APIs. If the program discovers states from runtime events, an enum may be clearer than a separate generic type for every state. If a value crosses one small internal function and has no meaningful invalid form there, a primitive may be enough.
The practical rule is to give each invariant one clear owner. Let a constructor validate the raw value, let a composite type validate relationships between fields, and let boundary adapters use those constructors. After that, the rest of the program can work with the meaning of the type instead of repeating the same defensive check.