Advanced Types
Rust's type system goes well beyond the basics. Once you understand ownership,
generics, and traits, a handful of advanced type features become available that let
you write more expressive, safer, and more reusable code. This page covers type
aliases, the newtype pattern, the never type, dynamically sized types, the
Sized trait, function pointers, and returning closures.
Type Aliases with type
A type alias gives an existing type a new name. The alias and the original type are completely interchangeable — the compiler treats them as identical. Aliases exist purely to reduce repetition and improve readability.
type Kilometers = i32;
fn add_distances(a: Kilometers, b: Kilometers) -> Kilometers {
a + b
}
fn main() {
let x: Kilometers = 5;
let y: i32 = 10;
// Kilometers and i32 are the same type — no conversion needed
let z = add_distances(x, y);
println!("Total: {} km", z);
}Total: 15 km
Type aliases shine when a long, complex type appears repeatedly. A classic example is
a Result specialised for I/O errors:
use std::io;
use std::fmt;
// Without alias — repetitive
fn write_something(_f: &mut dyn fmt::Write) -> Result<(), io::Error> { todo!() }
fn read_something() -> Result<String, io::Error> { todo!() }
// With alias — cleaner
type IoResult<T> = Result<T, io::Error>;
fn write_cleaner(_f: &mut dyn fmt::Write) -> IoResult<()> { todo!() }
fn read_cleaner() -> IoResult<String> { todo!() }Kilometers with plaini32. If you need the compiler to enforce the distinction, use the newtype pattern instead.The Newtype Pattern
The newtype pattern wraps an existing type in a single-field tuple struct. Unlike
a type alias, this creates a genuinely distinct type. The compiler will refuse to
mix up a Meters value and a Kilograms value even though both wrap f64
internally.
struct Meters(f64);
struct Kilograms(f64);
fn travel(distance: Meters) {
println!("Travelling {:.1} metres", distance.0);
}
fn main() {
let d = Meters(42.0);
let _m = Kilograms(70.0);
travel(d);
// travel(_m); // ERROR: expected Meters, found Kilograms
}Travelling 42.0 metres
The newtype pattern also solves the orphan rule limitation. Rust requires that either the trait or the type being implemented must be defined in your crate. If you want to implement a foreign trait on a foreign type, wrap it in a newtype first.
use std::fmt;
// We cannot implement Display directly on Vec<String> — both are foreign.
// Wrap it in a newtype first.
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![
String::from("hello"),
String::from("world"),
]);
println!("{}", w);
}[hello, world]
Deref to expose them, or explicitly delegate the methods you need.The Never Type !
Rust has a special type called the never type, written !. It is the return type
of expressions that never produce a value — functions that always panic, infinite
loops, or early returns. The never type can coerce to any other type, which makes it
valid in positions where the compiler needs all match arms to agree on a type.
// This function never returns — its return type is !
fn forever() -> ! {
loop {
println!("spinning...");
}
}
// panic! also has type !
fn fail(msg: &str) -> ! {
panic!("{}", msg);
}
fn main() {
let mut optional: Option<i32> = Some(5);
// 'break' inside a loop has type ! — all arms agree on i32
let value: i32 = loop {
let n = match optional.take() {
Some(v) => v,
None => break 42,
};
println!("got: {}", n);
optional = None;
};
println!("final value: {}", value);
}got: 5 final value: 42
The never type is why continue and return can appear in match arms that are
expected to produce a value — both have type !, which coerces to any type:
fn parse_or_skip(s: &str) -> Vec<i32> {
let mut results = Vec::new();
for token in s.split_whitespace() {
// 'continue' has type ! which coerces to i32 — so both arms produce i32
let n: i32 = match token.parse() {
Ok(v) => v,
Err(_) => continue,
};
results.push(n);
}
results
}
fn main() {
let data = "1 two 3 four 5";
println!("{:?}", parse_or_skip(data));
}[1, 3, 5]
Dynamically Sized Types (DSTs)
Most Rust types have a size known at compile time. A handful do not — these are called Dynamically Sized Types (DSTs). The three most common are:
str— a string of unknown byte length[T]— a slice of unknown element countdyn Trait— a trait object whose concrete type is unknown at compile time
Because the compiler cannot know their size, DSTs can never appear directly on the
stack. They must always be behind a pointer: &str, &[T],
Box<dyn Trait>, Rc<dyn Trait>, etc.
trait Greet {
fn hello(&self);
}
struct English;
struct Spanish;
impl Greet for English { fn hello(&self) { println!("Hello!"); } }
impl Greet for Spanish { fn hello(&self) { println!("Hola!"); } }
fn greet_everyone(greeters: &[Box<dyn Greet>]) {
for g in greeters {
g.hello();
}
}
fn main() {
let greeters: Vec<Box<dyn Greet>> = vec![
Box::new(English),
Box::new(Spanish),
];
greet_everyone(&greeters);
}Hello! Hola!
DST | Behind a pointer as | Common use |
|---|---|---|
str | &str, Box<str> | String slices in function parameters |
[T] | &[T], Box<[T]> | Slice parameters, array views |
dyn Trait | &dyn Trait, Box<dyn Trait> | Runtime polymorphism / trait objects |
The Sized Trait and ?Sized
Every type whose size is known at compile time automatically implements the Sized
marker trait. Generic type parameters implicitly carry a Sized bound — writing
fn foo<T>(x: T) is actually fn foo<T: Sized>(x: T).
To write a generic function that also accepts DSTs, relax this bound with ?Sized.
The parameter must then be behind a pointer, since its size is unknown:
use std::fmt::Debug;
// T: Sized is implicit — only sized types allowed
fn print_sized<T: Debug>(val: T) {
println!("{:?}", val);
}
// T: ?Sized relaxes the bound — DSTs allowed, but T must be behind a reference
fn print_any<T: Debug + ?Sized>(val: &T) {
println!("{:?}", val);
}
fn main() {
print_sized(42);
print_sized("hello"); // &str is Sized (it is a fat pointer)
print_any(&42);
print_any("hello"); // str is ?Sized — works because we pass &str
print_any(&[1, 2, 3][..]); // [i32] is ?Sized — works because we pass &[i32]
}42 "hello" 42 "hello" [1, 2, 3]
?Sized syntax is read "optionally Sized" — the type may or may not be sized. You rarely write it yourself, but understanding it explains why many standard-library functions accept &T where T: ?Sized.Function Pointers
In addition to closures, Rust has function pointers — the type
fn(i32) -> i32 (lowercase fn, not the Fn trait). A function pointer
holds the address of a specific named function, not an environment-capturing closure.
It is useful in FFI and wherever you need a Copy-able callable.
Function pointers implement all three Fn traits (Fn, FnMut, FnOnce),
so they can be used anywhere a closure is expected.
fn add_one(x: i32) -> i32 { x + 1 }
fn double(x: i32) -> i32 { x * 2 }
// fn(i32) -> i32 is a concrete function pointer type
fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
fn main() {
// Store a function pointer in a variable
let op: fn(i32) -> i32 = add_one;
println!("add_one(5) = {}", op(5));
// Pass as an argument
println!("double(4) = {}", apply(double, 4));
// Collect into a Vec and iterate
let ops: Vec<fn(i32) -> i32> = vec![add_one, double];
for f in &ops {
print!("{} ", f(10));
}
println!();
// Function pointers work with map
let numbers = vec![1, 2, 3];
let incremented: Vec<i32> = numbers.into_iter().map(add_one).collect();
println!("{:?}", incremented);
}add_one(5) = 6 double(4) = 8 11 20 [2, 3, 4]
Feature | fn pointer | Closure (impl Fn) |
|---|---|---|
Captures environment | No | Yes |
Copy | Yes | Only if all captures are Copy |
Works in FFI (C-compatible) | Yes | No |
Size | One pointer (8 bytes) | Size of captured variables |
Implements Fn traits | Yes (all three) | Yes (one or more) |
Returning Closures from Functions
Closures have anonymous, compiler-generated types, so you cannot name the return type directly. Two options:
impl Fn(...) -> ...— the concrete type is inferred at compile time; zero overhead and the preferred approach.Box<dyn Fn(...) -> ...>— a heap-allocated trait object; necessary when the returned closure type differs between branches at runtime.
// impl Fn — preferred; one concrete closure always returned
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
// Box<dyn Fn> — required when the closure type varies at runtime
fn make_op(multiply: bool) -> Box<dyn Fn(i32) -> i32> {
if multiply {
Box::new(|x| x * 10)
} else {
Box::new(|x| x + 10)
}
}
fn main() {
let add5 = make_adder(5);
let add20 = make_adder(20);
println!("add5(3) = {}", add5(3)); // 8
println!("add20(3) = {}", add20(3)); // 23
let f = make_op(true);
let g = make_op(false);
println!("f(4) = {}", f(4)); // 40
println!("g(4) = {}", g(4)); // 14
}add5(3) = 8 add20(3) = 23 f(4) = 40 g(4) = 14
impl Fn works only when every code path returns the same concrete closure type. If different branches return different closures, the compiler rejects it with a type mismatch — useBox<dyn Fn> in that case.Putting It All Together
Here is a small example that uses a newtype, a type alias, and a returned closure together — patterns common in real-world Rust codebases.
// Type alias reduces noise
type Transform = fn(f64) -> f64;
// Newtypes enforce units
struct Celsius(f64);
struct Fahrenheit(f64);
impl Celsius {
fn to_fahrenheit(&self) -> Fahrenheit {
Fahrenheit(self.0 * 9.0 / 5.0 + 32.0)
}
}
fn square(x: f64) -> f64 { x * x }
fn negate(x: f64) -> f64 { -x }
fn halve(x: f64) -> f64 { x / 2.0 }
fn make_scaler(factor: f64) -> impl Fn(f64) -> f64 {
move |x| x * factor
}
fn main() {
let c = Celsius(100.0);
println!("100 C = {:.1} F", c.to_fahrenheit().0);
let transforms: Vec<(&str, Transform)> = vec![
("square", square),
("negate", negate),
("halve", halve),
];
for (name, f) in &transforms {
println!("{:>6}(4.0) = {}", name, f(4.0));
}
let triple = make_scaler(3.0);
println!("triple(7.0) = {}", triple(7.0));
}100 C = 212.0 F square(4.0) = 16 negate(4.0) = -4 halve(4.0) = 2 triple(7.0) = 21