Skip to main content

19.3 - Advanced Types

Using the Newtype Pattern for Type Safety and Abstraction

In the previous section, we discussed using the newtype pattern to wrap an existing type in a tuple.

The newtype pattern is useful in a few other scenarios too. If we create a Millisecond type:

struct Millisecond(u32);

fn sleep(duration: Millisecond) {
// --snip--
}

This makes it very clear that sleep expects a value in milliseconds (although in this particular example you'd be better off using std::time::Duration. The newtype pattern can also be used to wrap a type and give it a different public API.

Creating Type Synonyms with Type Aliases

We can give an existing type a type alias:

type Kilometers = i32;

let x: i32 = 5;
let y: Kilometers = 5;

println!("x + y = {}", x + y);

This creates a new type called Kilometers which is an alias for i32. You can now use these types interchangeably - if a function expects a Kilometers you can pass in an i32 instead or vice versa.

The main use case for type aliases is to reduce the length of long type names. We can take code like this:

let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}

fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
}

and turn it into:

type Thunk = Box<dyn Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

fn takes_long_type(f: Thunk) {
// --snip--
}

fn returns_long_type() -> Thunk {
// --snip--
}

A meaningful name for your alias can make your code much easier to read and write. Another example of this is in std::io. Many functions here return a Result with a std::io::Error as the error type, so std:io defines:

type Result<T> = std::result::Result<T, std::io::Error>;

which makes a lot of function signatures in this module much shorter and easier to read.

The Never Type that Never Returns

There's a special type named !. This is the empty type or never type:

fn bar() -> ! {
// --snip--
}

Here this tells the compiler that the bar function will never return. Way back in chapter 2 we wrote this code:

loop {
// --snip--
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
// --snip--
}

The arms of a match are supposed to all be of the same type in order for it to compile. You can't have one arm evaluate to a u32 and another evaluate to a String. Here though, we know that the Err(_) arm isn't going to return anything - if we get here, we'll abort this run through the loop and continue. From a type perspective, the return value of continue is !, so here Rust knows it's safe to ignore this arm (or to put it another way, the ! type can be coerced to any other type).

The panic! macro is another example of something that evaluates to the ! type:

impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}

A loop without a break is also of type !.

Dynamically Sized Types and the Sized Trait

When we create a variable on the stack, Rust needs to know how much space to allocate for that variable at compile time. For example:

fn add(a: i32, b: i32) {
println!("{}", a + b);
}

When someone calls add, Rust will need to allocate four bytes on the stack to hold a, and another four to hold b.

Consider a string, which holds a variable amount of data:

fn say_hello(name: &str) {
println!("Hello {name}");
}

Here name isn't actually a str but a &str or a string slice. The actual data from the string is stored "somewhere else" in memory, but the name variable itself is 16 bytes long on a 64-bit platform (two usizes). This is because &str is implemented as a pointer to the string data and a length value.

As a rule, to pass around dynamically sized types like a string, we need a pointer. This can be a Box or an Rc or a &, but some kind of pointer. Another example of a dynamically sized type is a trait object, which is why when we pass one it's usually in a Box<dyn Trait>. The size of the trait object itself is unknown, so we pass around a smart pointer to the trait object instead, allowing us to store the trait object on the heap.

Any type whose size is known at compile time automatically implements the Sized trait. Generic functions implicitly get a trait bounds for Sized:

// You write this:
fn my_generic_fn<T>(t: T) {
// --snip--
}

// But Rust implicitly turns this into:
fn my_generic_fn<T: Sized>(t: T) {
// --snip--
}

You can prevent this with this syntax:

// T doesn't have to be `Sized`.
fn generic<T: ?Sized>(t: &T) {
// --snip--
}

Note that in order to do this, we can't leave the t parameter of type T. We again need some kind of pointer, in this case we chose &T.