DEV Community

Gregory Chris
Gregory Chris

Posted on

Creating Domain Types for Safer Code

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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),
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Valid email created: Email("[email protected]")
Error: Invalid email: not_an_email
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Domain types prevent bugs by making your code more type-safe and expressive.
  2. Use tuple structs to create lightweight wrappers around primitives.
  3. Add validation logic at construction to ensure data integrity.
  4. Implement Deref for better ergonomics when working with the inner value.
  5. 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:

  1. Refactor an existing Rust project to use domain types for key primitives.
  2. Explore advanced patterns, like using smart pointers (Box, Rc) inside domain types.
  3. 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)