RustAdvanced Types

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.

RUST
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:

RUST
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!() }
Note
Because a type alias does not create a new type, the compiler will not prevent you from mixing 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.

RUST
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.

RUST
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]
Tip
The trade-off with newtypes is that the inner type's methods are not automatically available on the wrapper. You can implement 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.

RUST
// 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:

RUST
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 count
  • dyn 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&lt;dyn Trait&gt;, Rc&lt;dyn Trait&gt;, etc.

RUST
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&lt;T&gt;(x: T) is actually fn foo&lt;T: Sized&gt;(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:

RUST
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]
Note
The ?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) -&gt; 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.

RUST
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:

  1. impl Fn(...) -&gt; ... — the concrete type is inferred at compile time; zero overhead and the preferred approach.
  2. Box&lt;dyn Fn(...) -&gt; ...&gt; — a heap-allocated trait object; necessary when the returned closure type differs between branches at runtime.

RUST
// 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
Warning
Returning 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.

RUST
// 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
Success
Rust's advanced type features give you precise control over type identity, memory layout, and runtime behaviour — without sacrificing zero-cost abstractions. Type aliases reduce noise, newtypes enforce invariants, the never type keeps exhaustive matching consistent, DSTs enable runtime polymorphism, and function pointers bridge the gap to C-compatible code.