🎓 A1Learner
🔍

Rust Advanced Types & Traits

Advanced type tools are the quiet specialists that make library APIs precise. A newtype wraps one value in its own type, like an ID card for one kind of ID.

Give a type a second name

type writes an alias — a second name for the same type, not a new type:

type Kilometres = i32;

Unwrap a newtype

Split a newtype in the let line to hand back the plain value inside:

struct Miles(f64);
let distance = Miles(3.5);
let Miles(raw) = distance;
println!("raw = {raw}");

The never type fits anywhere

panic! never returns, so its type is ! — and ! is accepted wherever a value is owed:

let number: i32 = match "42".parse() {
    Ok(n) => n,
    Err(_) => panic!("bad number"),
};
println!("number = {number}");

A pointer to an unsized type is fat

A normal reference is just one address. A reference to a dynamically sized type stores a second word. The word sits beside the address. For slices and str it is a length. For dyn Trait it is a vtable pointer. On a 64-bit machine each word is 8 bytes.

println!("&u8 = {} bytes", size_of::<&u8>());
println!("&[u8] = {} bytes", size_of::<&[u8]>());
println!("&str = {} bytes", size_of::<&str>());
println!("&dyn Display = {} bytes", size_of::<&dyn std::fmt::Display>());

?Sized lets a generic accept unsized types

Every type parameter secretly demands Sized. Writing T: ?Sized lifts that one demand, so a single function can accept both a sized String and the unsized str.

fn shout<T: ?Sized + std::fmt::Display>(value: &T) {
    println!("{value}!");
}

fn main() {
    shout("hello");
    shout(&String::from("hi"));
}

A blanket impl covers every type at once

Write the impl once, for every type meeting a condition, and all of them gain the method:

trait Loud { fn shout(&self) -> String; }
impl<T: std::fmt::Display> Loud for T {
    fn shout(&self) -> String { format!("{}!", self) }
}
println!("{}", 42.shout());

Example

#[derive(Debug)] struct UserId(u64); #[derive(Debug)] struct OrderId(u64);
fn find_user(id: UserId) -> String { format!("user #{}", id.0) }
fn main() { println!("{}", find_user(UserId(42))); /* find_user(OrderId(42)) would NOT compile */ }

Key points

  • Alias names use UpperCamelCase, like other type names: the compiler warns when an alias name starts with a lower-case letter
  • A supertrait says "to implement A you must first implement B": trait A: B
  • An alias is only a second name
  • It is not a new type
  • A Kilometers value and an i32 mix freely
  • The compiler gives no mix-up protection
  • Inside a trait definition, Self is ?Sized by default — that is how traits can be implemented for str and [T], which have no fixed size
  • A struct may hold a dynamically sized type only as its last field
  • Then the struct itself has no fixed size either
  • A user ID 42 and an order ID 42 are both numbers, and Rust can make mixing them up impossible at compile time, for free.

In this example, UserId and OrderId are both one-field structs wrapping a u64. find_user accepts only a UserId. Passing UserId(42) works, and the function prints user #42 by reading the .0 inside. The commented-out call shows the point: find_user(OrderId(42)) would not compile, even though both wrap the same number. A whole class of production bug — passing the wrong ID — is deleted by two tiny type definitions.

Note

Most of these tools cost nothing at run time

They exist purely so the compiler can catch design mistakes — the type system as a second pair of eyes

Try it Yourself

Edit the code, then press Run.

Running is simulated · Rust Playground

Exercise

What problem does the newtype pattern solve?