RustArena

Step 2 of 7

Newtypes: give plain values a meaning

In 1999 the Mars Climate Orbiter was lost because one team's software produced pound-force seconds and another's expected newton seconds. Both were just numbers. In your code it's fn transfer(from: u64, to: u64, amount: u64): nothing stops a caller from swapping from and to.

A newtype is a tuple struct with one field that gives a plain value its own type:

struct UserId(u64);
struct OrderId(u64);

fn cancel_order(user: UserId, order: OrderId) {
    println!("user {} cancels order {}", user.0, order.0);
}

fn main() {
    let user = UserId(7);
    let order = OrderId(1001);
    cancel_order(order, user);
}
error[E0308]: arguments to this function are incorrect

The mix-up is now a compile error, and rustc even notices the two arguments are swapped and suggests the fix. Newtypes cost nothing at run time: a UserId is laid out exactly like a u64.

Parse, don't validate

A newtype with a private field goes further: if the only way to create one is a function that checks the input, then every value of that type is valid, everywhere, forever. Functions that take an Email never need to check it again.

mod email {
    pub struct Email(String);

    impl Email {
        pub fn parse(s: &str) -> Result<Email, String> {
            let s = s.trim();
            match s.split_once('@') {
                Some((user, domain)) if !user.is_empty() && domain.contains('.') => Ok(Email(s.to_lowercase())),
                _ => Err(format!("invalid email: {s:?}")),
            }
        }

        pub fn as_str(&self) -> &str {
            &self.0
        }
    }
}

use email::Email;

fn send_welcome(to: &Email) -> String {
    format!("welcome mail sent to {}", to.as_str())
}

fn main() {
    for input in [" [email protected] ", "not-an-email", "@arena.dev"] {
        match Email::parse(input) {
            Ok(e) => println!("{}", send_welcome(&e)),
            Err(e) => println!("{e}"),
        }
    }
}
welcome mail sent to [email protected]
invalid email: "not-an-email"
invalid email: "@arena.dev"

send_welcome can't receive an unchecked string: outside the email module, Email(String::from("oops")) doesn't compile (error[E0603]: tuple struct constructor `Email` is private). The validation happens once, at the edge of the program, which is the idea behind the slogan parse, don't validate.

What newtypes are good for

Derive what the type needs (Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord) and add accessors like as_str or value. Avoid implementing Deref just to "inherit" all the inner type's methods: it brings back the operations the newtype was meant to hide.

Common mistakes

Your task

Write Cents, a money type. Cents::parse(s) accepts dollar amounts like "12.34", "12.3", "5" or "$5" (an optional leading $) and returns the amount in cents. Anything else (empty, a minus sign, no digit before the ., more than two decimals, letters) is Err(format!("invalid amount {s:?}")). Implement Add so two Cents can be added, and Display so Cents(1234) prints $12.34.

Challenge 2

Stop ids from being mixed up. Create newtypes UserId(u64) and OrderId(u64) (derive Debug, Clone, Copy, PartialEq, Eq, Hash) and change cancel to take them, so cancel(order, user) no longer compiles. It returns user 7 cancelled order 1001 and records the cancellation in the set.

Challenge 3

Use a newtype to get around the orphan rule. Table wraps rows of cells. Implement Display so each row prints its cells joined by |, rows separated by newlines (no newline at the end), and FromStr that parses the same format back (split rows on \n, cells on |, trimming each cell). Parsing never fails, so use type Err = std::convert::Infallible;.

Previous: Constructors, Default and builders Next: Make invalid states unrepresentable