Creating Domain Types for Safer Code in Rust
As software developers, we often work with data that carries implicit meaning—an email address, a username, an ID, or a URL. While these may all be represented as String
or &str
in many programming languages, treating them as interchangeable primitives can lead to subtle and hard-to-detect bugs. What if you accidentally pass a username where an email was expected? Or mix up two IDs from different contexts?
In Rust, we can use domain types to make our code safer, more expressive, and easier to maintain. By wrapping primitives in newtypes, we ensure that our compiler helps us catch mistakes at compile-time, rather than letting them slip through to runtime.
In this post, we'll dive into the concept of domain types, explore how to implement them in Rust using tuple structs with validation, and discuss how they improve both code safety and readability. By the end, you'll be equipped to start using domain types in your own projects to prevent bugs and make your code more robust.
Why Domain Types? A Real-World Analogy
Imagine you're filling out a form at the DMV, and the clerk asks for your email address and your username. Both look like strings, but they're fundamentally different pieces of information. If you mistakenly swap the two, the DMV's system might send your account confirmation email to your username! The form should treat these two fields differently to ensure you can't mix them up.
In Rust, if we use plain String
or &str
for both Email
and Username
, the compiler won't know the difference between them. This makes it easy to accidentally pass the wrong value to a function or API. Domain types solve this by creating a new, distinct type for each piece of data. They're like labeled containers that say, "Hey, I'm an email!" or "Hey, I'm a username!"—and the compiler enforces this distinction.
Introducing Newtypes in Rust
Rust's newtype pattern is a lightweight way to create custom types by wrapping a single value (e.g., a String
, i32
, etc.) inside a tuple struct. Here's an example:
struct Email(String);
struct Username(String);
Now, Email
and Username
are distinct types, even though they both wrap a String
. If you try to pass an Email
where a Username
is expected, the compiler will throw an error. Let's see this in action:
fn send_welcome_email(email: Email) {
println!("Sending welcome email to {}", email.0);
}
fn main() {
let email = Email("[email protected]".to_string());
let username = Username("cool_username".to_string());
send_welcome_email(email);
// This won't compile, and that's a good thing!
// send_welcome_email(username);
}
The type safety provided here is invaluable, especially as your codebase grows.
Adding Validation to Domain Types
While the above example is helpful, it doesn't guarantee that the data inside an Email
or Username
is valid. For instance, someone could create an Email
like this:
let invalid_email = Email("not_an_email".to_string());
To enforce validity, we can add validation logic during construction. Instead of directly exposing the struct fields, we create a constructor that validates the input and returns a Result
:
#[derive(Debug)]
struct Email(String);
impl Email {
fn new(value: String) -> Result<Self, String> {
if value.contains('@') {
Ok(Email(value))
} else {
Err(format!("Invalid email: {}", value))
}
}
}
Now, any attempt to create an Email
must pass through the validation logic:
fn main() {
match Email::new("[email protected]".to_string()) {
Ok(email) => println!("Valid email created: {:?}", email),
Err(err) => println!("Error: {}", err),
}
match Email::new("not_an_email".to_string()) {
Ok(email) => println!("Valid email created: {:?}", email),
Err(err) => println!("Error: {}", err),
}
}
Output:
Valid email created: Email("[email protected]")
Error: Invalid email: not_an_email
By enforcing validation at the type level, we ensure that invalid data never enters our system.
Making Domain Types Ergonomic with Deref
You may have noticed that accessing the inner String
of an Email
requires .0
, which can be a bit clunky. To improve ergonomics, we can implement the Deref
trait, allowing the inner value to behave like a String
:
use std::ops::Deref;
impl Deref for Email {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
Now, you can treat an Email
like a &str
in most contexts:
fn print_email(email: &Email) {
println!("Email: {}", email);
}
fn main() {
let email = Email::new("[email protected]".to_string()).unwrap();
print_email(&email);
// You can also call String methods directly
println!("Email length: {}", email.len());
}
Common Pitfalls and How to Avoid Them
While domain types are powerful, there are a few pitfalls to watch out for:
1. Overhead of Wrapping and Unwrapping
Creating a domain type involves wrapping and unwrapping the inner value. Overuse can lead to cumbersome code. To avoid this, only create domain types where they add real value (e.g., enforcing invariants or preventing mix-ups).
2. Validation Complexity
Validation logic can become complex for certain types (e.g., validating a URL). Consider using libraries like regex
or url
to simplify validation:
extern crate url;
use url::Url;
#[derive(Debug)]
struct WebsiteUrl(String);
impl WebsiteUrl {
fn new(value: String) -> Result<Self, String> {
if Url::parse(&value).is_ok() {
Ok(WebsiteUrl(value))
} else {
Err(format!("Invalid URL: {}", value))
}
}
}
3. Serialization
If you're working with serialization frameworks like serde
, you'll need to implement or derive the appropriate traits for your domain types:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Email(String);
Key Takeaways
- Domain types prevent bugs by making your code more type-safe and expressive.
- Use tuple structs to create lightweight wrappers around primitives.
- Add validation logic at construction to ensure data integrity.
- Implement
Deref
for better ergonomics when working with the inner value. - Be mindful of pitfalls like excessive wrapping and complex validation.
Next Steps
If you're excited about using domain types in Rust, here are some next steps:
- Refactor an existing Rust project to use domain types for key primitives.
- Explore advanced patterns, like using smart pointers (
Box
,Rc
) inside domain types. - Learn more about the
newtype
pattern and its applications in other contexts (e.g., zero-cost abstractions).
Domain types are a powerful tool in the Rust programmer's toolkit. By embracing them, you can write safer, more maintainable code and let the compiler catch errors that would otherwise slip through. So, go forth and wrap your primitives—you'll thank yourself later! 🦀
Happy coding! 🚀
Top comments (0)