Rust’s type system goes far beyond structs, enums, and generics. Several advanced type features give you precise control over how types relate to each other, what they represent, and how much the compiler can verify at compile time. Understanding them lets you write safer, more expressive APIs.
The Newtype Pattern
Wrapping an existing type in a single-field tuple struct creates a new, distinct type with the same representation. The wrapper is compiled away — zero runtime cost, but strong compile-time guarantees:
struct UserId(u64);
struct ProductId(u64);
struct Email(String);
struct Celsius(f64);
struct Fahrenheit(f64);
fn get_user(id: UserId) { println!("User {}", id.0); }
fn get_product(id: ProductId) { println!("Product {}", id.0); }
fn main() {
let user = UserId(42);
let product = ProductId(42);
get_user(user);
get_product(product);
// Type error — can't accidentally swap them:
// get_user(product); // compile error!
// Unit conversion with type safety
let boiling = Celsius(100.0);
let fahrenheit = Fahrenheit(boiling.0 * 9.0 / 5.0 + 32.0);
println!("{:.1}°F", fahrenheit.0); // 212.0°F
}
Implementing Traits for Newtypes
The newtype pattern also lets you implement external traits for external types (working around the orphan rule):
use std::fmt;
// Can't implement Display for Vec<T> directly (orphan rule)
// But we can wrap it:
struct CommaSeparated(Vec<String>);
impl fmt::Display for CommaSeparated {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.join(", "))
}
}
fn main() {
let items = CommaSeparated(vec!["apple".to_string(), "banana".to_string(), "cherry".to_string()]);
println!("{}", items); // apple, banana, cherry
}
Validation Newtypes
Newtypes can enforce invariants at the boundary:
#[derive(Debug, Clone)]
pub struct Email(String);
impl Email {
pub fn new(s: &str) -> Result<Self, String> {
if s.contains('@') && s.contains('.') {
Ok(Email(s.to_string()))
} else {
Err(format!("'{}' is not a valid email", s))
}
}
pub fn value(&self) -> &str { &self.0 }
}
#[derive(Debug)]
pub struct Port(u16);
impl Port {
pub fn new(n: u16) -> Result<Self, String> {
if n > 0 { Ok(Port(n)) } else { Err("Port must be > 0".to_string()) }
}
pub fn value(&self) -> u16 { self.0 }
}
fn connect(email: &Email, port: &Port) {
println!("Connecting {} on port {}", email.value(), port.value());
}
fn main() {
let email = Email::new("[email protected]").unwrap();
let port = Port::new(8080).unwrap();
connect(&email, &port);
let bad = Email::new("not-an-email");
println!("{:?}", bad); // Err("...")
}
Type Aliases
A type alias creates an alternate name for an existing type. Unlike newtype, it does not create a distinct type — it’s purely a readability aid:
type Meters = f64;
type Seconds = f64;
type Velocity = f64; // meters per second
fn speed(distance: Meters, time: Seconds) -> Velocity {
distance / time
}
fn main() {
let v: Velocity = speed(100.0, 9.58);
println!("{:.2} m/s", v); // 10.44 m/s
// Note: Meters and Seconds are both f64 — NO type safety here!
// speed(9.58, 100.0) would compile without error (wrong args)
// For safety, use newtypes instead
}
The main use case is reducing verbosity in complex types:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
// Without alias:
fn register_handler(
handlers: Arc<Mutex<HashMap<String, Box<dyn Fn(i32) -> String + Send>>>>
) {}
// With alias:
type HandlerMap = HashMap<String, Box<dyn Fn(i32) -> String + Send>>;
type SharedHandlers = Arc<Mutex<HandlerMap>>;
fn register_handler_clean(handlers: SharedHandlers) {}
// Result alias pattern — very common in library crates
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn read_config() -> Result<String> {
Ok(std::fs::read_to_string("config.toml")?)
}
The Never Type !
The never type ! represents a computation that never completes — a function that panics, loops forever, or exits the process. It can be coerced to any other type, which is why panic!() can appear in any expression position:
fn server_loop() -> ! {
loop {
// handle requests forever
println!("serving...");
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
fn fail(msg: &str) -> ! {
eprintln!("Fatal: {}", msg);
std::process::exit(1);
}
fn main() {
// Works in match because `!` coerces to any type
let x: i32 = match Some(42) {
Some(n) => n,
None => panic!("impossible"), // type: !
};
// Works in if-let else
let config = std::env::var("APP_PORT").unwrap_or_else(|_| "8080".to_string());
let port: u16 = config.parse().unwrap_or_else(|_| {
// This block has type `!` — it diverges
eprintln!("Invalid port, using default");
8080 // Actually this is u16, so block is NOT diverging here
});
println!("Port: {}", port);
}
! in Error Handling
The ? operator on Option<T> works because None branches return ! when the function returns Result:
// Infallible conversion — used in contexts where an error is impossible
use std::convert::Infallible;
// This is essentially `!` but as a concrete type
fn always_succeeds(s: &str) -> Result<String, Infallible> {
Ok(s.to_uppercase())
}
Dynamically Sized Types (DSTs)
Most Rust types have sizes known at compile time. DSTs don’t — their size depends on runtime data. The two built-in DSTs are:
str— variable-length UTF-8 text[T]— variable-length slice ofT
You can never have a DST as a value directly. You always access them through a fat pointer (which stores both the data address and the length):
fn main() {
// Can't write: let s: str = "hello"; — unknown size
let s: &str = "hello"; // fat pointer: (address, length)
let s2: Box<str> = "hello".into(); // heap-allocated fat pointer
let s3: Rc<str> = "hello".into(); // reference-counted fat pointer
// Same with slices:
let arr = [1, 2, 3, 4, 5];
let slice: &[i32] = &arr[1..4]; // fat pointer: (address, 3)
println!("len: {}", s.len());
println!("{:?}", slice);
}
Trait Objects Are DSTs Too
dyn Trait is also a DST. The fat pointer for a trait object stores the data address AND a vtable pointer:
trait Animal { fn speak(&self); }
struct Dog;
impl Animal for Dog { fn speak(&self) { println!("Woof!"); } }
fn main() {
let dog: &dyn Animal = &Dog; // fat pointer: (data, vtable)
let boxed: Box<dyn Animal> = Box::new(Dog); // heap + vtable
dog.speak();
boxed.speak();
}
The Sized Trait and ?Sized
Sized is automatically implemented for all types with compile-time-known sizes. Generic functions implicitly require Sized:
fn print<T: std::fmt::Display>(t: T) {
// T: Sized is implicit — T must have known size
println!("{}", t);
}
Use ?Sized to accept DSTs (through a pointer):
use std::fmt::Display;
// Only accepts Sized types (implicit)
fn print_sized(t: impl Display) {
println!("{}", t);
}
// Accepts Sized AND unsized types (via reference)
fn print_maybe_unsized<T: Display + ?Sized>(t: &T) {
println!("{}", t);
}
fn main() {
print_sized("hello"); // &str is Sized (it's a fat pointer)
print_maybe_unsized("hello"); // str is unsized, but &str is ok
let s: Box<str> = "world".into();
print_maybe_unsized(&*s); // Deref Box<str> to str, then take &str
}
PhantomData<T> — Zero-Size Type Markers
PhantomData lets you tell the compiler a type “logically uses” another type without storing it. Used for:
- Lifetime variance
- Type-state patterns
- Marker types in generic structs
use std::marker::PhantomData;
// A typed ID that distinguishes User IDs from Product IDs at compile time
struct Id<Kind> {
value: u64,
_marker: PhantomData<Kind>, // Zero-size, no runtime cost
}
struct User;
struct Product;
impl<Kind> Id<Kind> {
fn new(value: u64) -> Self {
Id { value, _marker: PhantomData }
}
fn value(&self) -> u64 { self.value }
}
fn get_user(id: Id<User>) { println!("User #{}", id.value()); }
fn get_product(id: Id<Product>) { println!("Product #{}", id.value()); }
fn main() {
let user_id: Id<User> = Id::new(42);
let product_id: Id<Product> = Id::new(42);
get_user(user_id);
get_product(product_id);
// Type safety without runtime cost:
// get_user(product_id); // compile error!
}
Type-State Pattern
Encode state machine states in the type system so invalid transitions don’t compile:
use std::marker::PhantomData;
struct Locked;
struct Unlocked;
struct Safe<State> {
contents: String,
_state: PhantomData<State>,
}
impl Safe<Locked> {
fn new(contents: &str) -> Self {
Safe { contents: contents.to_string(), _state: PhantomData }
}
fn unlock(self, password: &str) -> Result<Safe<Unlocked>, Self> {
if password == "secret" {
Ok(Safe { contents: self.contents, _state: PhantomData })
} else {
Err(self) // Return locked safe back on failure
}
}
}
impl Safe<Unlocked> {
fn get_contents(&self) -> &str { &self.contents }
fn lock(self) -> Safe<Locked> {
Safe { contents: self.contents, _state: PhantomData }
}
}
fn main() {
let safe = Safe::<Locked>::new("secret documents");
// Can't read contents without unlocking:
// safe.get_contents(); // compile error!
let unlocked = safe.unlock("secret").expect("Wrong password");
println!("{}", unlocked.get_contents()); // secret documents
let _relocked = unlocked.lock();
// _relocked.get_contents(); // compile error — locked again
}
Summary
| Feature | Purpose | Runtime cost |
|---|---|---|
Newtype struct Foo(T) |
Type safety, orphan rule workaround, invariants | Zero |
type Alias = T |
Readability, reduce verbosity | Zero |
! (never type) |
Diverging functions, exhaustive match | Zero |
str, [T], dyn Trait |
DSTs — runtime-sized data | Fat pointer overhead |
?Sized bound |
Accept DST references in generics | Zero |
PhantomData<T> |
Variance, type-state, phantom ownership | Zero |
Resources
- Rust Book: Advanced Types
- Rustonomicon: Subtyping and Variance
- Rust Reference: Dynamically Sized Types
Comments