Advanced Types
Oxide's type system is powerful and flexible. Let's explore some advanced type features that enable you to write expressive, type-safe code.
Type Aliases
Type aliases give an existing type another name:
type Kilometers = Int
type Pounds = Int
fn main() {
let distance: Kilometers = 5
let weight: Pounds = 50
// These are the same underlying type, so they can be mixed
let total = distance + weight
println!("Total: \(total)") // Prints: Total: 55
}
The key point: type aliases create aliases, not new types. Kilometers and Pounds are both Int, so values of these types can be used interchangeably.
When to Use Type Aliases
Use aliases to:
- Reduce repetition - Shorten long type names
- Clarify intent - Make code more readable
- Refactor - Change a type in one place
// Reduce repetition
type Result<T> = std.result.Result<T, String>
fn readFile(path: &str): Result<String> {
// ...
}
fn parseJson(json: &str): Result<Value> {
// ...
}
The Result type alias shows up throughout the standard library, making error handling code more readable.
Generics in Type Aliases
Type aliases can be generic:
type Callback<T> = (T) -> T
fn applyTwice<T>(f: Callback<T>, x: T): T {
f(f(x))
}
fn main() {
let double: Callback<Int> = { x -> x * 2 }
println!("\(applyTwice(double, 5))") // Prints: 20
}
The Never Type !
The ! type, called the "never type," represents a function that never returns:
fn fail(msg: &str)! {
panic!("\(msg)")
}
fn loopForever()! {
loop {
println!("Forever!")
}
}
fn main() {
// Never type is compatible with any type
let x: Int = if condition {
5
} else {
fail("Error!") // Returns !
}
}
Why Never Type Matters
The never type is useful in several situations:
// In match expressions
fn example(x: Int) {
let msg = match x {
1 -> "one",
2 -> "two",
_ -> panic!("Unknown"), // Returns !
}
}
// In Option handling
fn getOrPanic(opt: Int?): Int {
opt.unwrapOrElse { panic!("No value") }
}
// In loops
fn keepAsking(): String {
loop {
let input = getUserInput()
if valid(input) {
return input
}
println!("Invalid!")
}
}
The never type allows these patterns to work because the compiler understands that ! can be treated as any type.
Dynamically Sized Types (DSTs)
Most types have a size known at compile time. But some types, called dynamically sized types (DSTs), don't:
// Sized type - compiler knows the size
let x: Int = 5
// DST - compiler doesn't know the size
let x: Vec<Int> = [1, 2, 3] // Error: Vec<Int> requires explicit construction
You can't use DSTs directly because Oxide needs to know the size at compile time. Instead, use references or pointers:
let x: &[Int] = &[1, 2, 3] // OK: reference to a slice
let x: &str = "hello" // OK: reference to a str
// Trait objects are also DSTs
let obj: &dyn Clone = &value // OK: reference to trait object
Deref Coercion
Oxide automatically converts between types when using the Deref trait. This is called deref coercion:
fn takesStr(s: &str) {
println!("\(s)")
}
fn main() {
let s = "hello".toString()
takesStr(&s) // Coerces String to &str
}
Under the hood, Oxide is calling the deref method:
StringimplementsDeref<Target = str>- So
&Stringis coerced to&str
The Deref Trait
You can implement Deref for your own types:
import std.ops.Deref
struct MyBox<T> {
value: T,
}
extension<T> MyBox<T>: Deref {
type Target = T
fn deref(): &T {
&value
}
}
fn main() {
let x = MyBox { value: 5 }
println!("\(*x)") // Prints: 5
}
Function Pointers
Function pointers let you pass functions like values:
fn add(a: Int, b: Int): Int {
a + b
}
fn multiply(a: Int, b: Int): Int {
a * b
}
fn executeOperation(op: (Int, Int) -> Int, a: Int, b: Int): Int {
op(a, b)
}
fn main() {
let result1 = executeOperation(add, 5, 3)
println!("add: \(result1)") // Prints: add: 8
let result2 = executeOperation(multiply, 5, 3)
println!("multiply: \(result2)") // Prints: multiply: 15
}
Function Pointers vs Closures
Function pointers (fn) are different from closure types:
// Function pointer - implements Fn, FnMut, FnOnce
let f: (Int) -> Int = { x -> x * 2 }
// Closure - captures environment
let multiplier = 3
let g = { x -> x * multiplier } // Can't assign to fn type!
// But closures can be assigned to function pointer types if they don't capture
let h: (Int) -> Int = { x -> x * 2 } // OK: no captured variables
When to use each:
- Function pointers (
fn) - When you need a simple function type without closure capture - Closures - When you need to capture variables from the environment
- Trait objects (
&dyn Fn) - For maximum flexibility
Function Item Types
When you write a function name without calling it, you get its item type:
fn add(a: Int, b: Int): Int {
a + b
}
fn main() {
// These are all equivalent
let f = add // Function item type (Int, Int) -> Int
let g: (Int, Int) -> Int = add
let h = add as (Int, Int) -> Int
println!("\(f(5, 3))") // Prints: 8
}
Function Traits
All functions and closures implement one of the Fn* traits:
// Regular function
fn regular(x: Int): Int { x * 2 }
// Closure with no captures
let noCapture = { x: Int -> x * 2 }
// Closure with immutable capture
let value = 3
let immutCapture = { x -> x * value }
// Closure with mutable capture
var counter = 0
let mutCapture = { counter += 1 }
// Closure that takes ownership
let owned = "hello".toString()
let moveCapture = move { owned.len() }
All of these can be used as function parameters:
fn applyTwice<F>(f: F, x: Int): Int
where
F: Fn(Int) -> Int,
{
f(f(x))
}
fn main() {
println!("\(applyTwice(regular, 2))") // Prints: 8
println!("\(applyTwice(noCapture, 2))") // Prints: 8
println!("\(applyTwice(immutCapture, 2))") // Prints: 27
}
Generic Trait Bounds
You can use complex trait bounds to express sophisticated type constraints:
// Multiple bounds with +
fn process<T>(item: T)
where
T: Clone + Display + Debug,
{
// Can use Clone, Display, and Debug methods
}
// Higher-ranked bounds
fn takesRefs<F>(f: F)
where
F: for<'a> Fn(&'a str) -> UInt,
{
// f can accept &str with any lifetime
}
// Where clauses for clarity
fn example<T, U>(t: T, u: U)
where
T: Clone,
U: Clone,
T: Display,
U: Display,
{
// Clearer than T: Clone + Display, U: Clone + Display
}
Type Inference Limitations
Oxide's type inference is powerful but not unlimited:
fn main() {
// Inference works
let v = vec![1, 2, 3] // Inferred as Vec<Int>
// Sometimes you need to help
let v: Vec<Int> = vec![] // Can't infer Int from empty vec
// Turbofish syntax for explicit types
let v = Vec<Int>.new()
let nums = "1,2,3".split(",").map { s -> s.parse<Int>().unwrap() }.collect<Vec<Int>>()
}
Phantom Types
Sometimes you want a generic parameter that doesn't actually store a value:
import std.marker.PhantomData
struct PhantomType<T> {
data: Int,
phantom: PhantomData<T>, // Has size 0
}
extension<T> PhantomType<T> {
static fn new(data: Int): Self {
PhantomType {
data,
phantom: PhantomData,
}
}
}
fn main() {
let p1: PhantomType<String> = PhantomType.new(5)
let p2: PhantomType<Int> = PhantomType.new(5)
// These are different types even though they have the same data
}
Phantom types are useful for:
- Maintaining type information without storing it
- Implementing type-safe abstractions
- Working with unsafe code
Generic Specialization
Sometimes you want different implementations for different types:
// Generic implementation
extension<T> Vec<T>: Clone
where
T: Clone,
{
fn clone(): Self {
// Clone each element
}
}
// Specialized for Copy types (faster)
extension Vec<Int>: Clone {
fn clone(): Self {
// Can use memcpy because Int is Copy
}
}
Advanced Example: Type-Safe Builder
Here's a real-world pattern using advanced types:
struct Builder<S> {
name: String?,
age: Int?,
phantom: PhantomData<S>,
}
trait BuilderState {}
struct NoName;
struct HasName;
struct Complete;
extension NoName: BuilderState {}
extension HasName: BuilderState {}
extension Complete: BuilderState {}
extension Builder<NoName> {
static fn new(): Self {
Builder {
name: null,
age: null,
phantom: PhantomData,
}
}
consuming fn name(name: String): Builder<HasName> {
Builder {
name: name,
age: self.age,
phantom: PhantomData,
}
}
}
extension Builder<HasName> {
consuming fn age(age: Int): Builder<Complete> {
Builder {
name: self.name,
age: age,
phantom: PhantomData,
}
}
}
extension Builder<Complete> {
consuming fn build(): Person {
Person {
name: name.unwrap(),
age: age.unwrap(),
}
}
}
struct Person {
name: String,
age: Int,
}
fn main() {
// Compile error: can't build without setting both fields
// let p = Builder.new().build()
// OK: set both fields
let p = Builder.new()
.name("Alice".toString())
.age(30)
.build()
println!("Person: \(p.name), age \(p.age)")
}
This pattern uses phantom types to enforce at compile time that the builder is in the correct state.
Summary
Advanced types in Oxide:
- Type aliases - Give names to complex types for clarity
- Never type - Represents functions that don't return
- DSTs and Deref - Work with unsized types safely
- Function pointers - Pass functions as values
- Function traits - Flexible function parameters
- Generic bounds - Express sophisticated constraints
- Phantom types - Type information without storage
- Specialization - Different implementations for different types
These features combine to give Oxide a type system that's both expressive and safe, letting you write code that's both correct by construction and readable.