Skip to main content

Advanced Types in Rust

Published: October 29, 2025 Updated: August 28, 2026 Larry Qu 7 min read

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 of T

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

Comments

👍 Was this article helpful?