Phantom Types in Rust and Go: Type Safety Without Runtime Overhead
Imagine you are running a package delivery service. Every package has a label - Fragile, Perishable, or Standard - that dictates how it should be handled.
Imagine you are running a package delivery service. Every package has a label - "Fragile", "Perishable", or "Standard" - that dictates how it should be handled.
Now imagine your delivery staff ignores these labels and treats every box the same. What could go wrong?
- A fragile package might get tossed.
- A perishable item might be delivered late.
- Your customers might never trust you again.
This is exactly what happens in software when we ignore the intent of data.
This is where phantom types come in - like invisible labels enforced at compile time, so your code cannot misuse data even if it looks the same at runtime.
What Are Phantom Types?
Phantom types are type parameters that do not exist at runtime but act as compile-time markers. They help enforce rules and transitions in your code using the type system.
Think of them as stickers on your data boxes: not seen inside the box, but they tell you exactly how the box should be handled.
Rust's Superpower: Zero-Cost Type Safety
Rust has a powerful type system that lets you use phantom types natively via PhantomData.
Rust Example: Package States
use std::marker::PhantomData;
struct Package<T> {
contents: String,
_marker: PhantomData<T>,
}
struct Fragile;
struct Perishable;
struct Standard;
impl Package<Fragile> {
fn handle_with_care(&self) {
println!("Handling fragile item: {}", self.contents);
}
}
impl Package<Perishable> {
fn refrigerate(&self) {
println!("Refrigerating: {}", self.contents);
}
}Usage
let box1 = Package::<Fragile> {
contents: "Glassware".into(),
_marker: PhantomData,
};
box1.handle_with_care();
// box1.refrigerate(); // Compile errorGo Does Not Have PhantomData... Or Does It?
Go does not have PhantomData, but with generics we can mimic this pattern.
Go Version
type Fragile struct{}
type Perishable struct{}
type Standard struct{}
type Package[T any] struct {
contents string
_ T
}
func HandleWithCare(p Package[Fragile]) {
fmt.Println("Handling fragile item:", p.contents)
}
func Refrigerate(p Package[Perishable]) {
fmt.Println("Refrigerating:", p.contents)
}Usage
box := Package[Fragile]{contents: "Glassware"}
HandleWithCare(box)
// Refrigerate(box) // Compile error!Real-Life Example: Bank Account Transaction Flow
Let us build a safe bank transaction workflow:
- Created
- Verified
- Approved
- Executed
Rust
use std::marker::PhantomData;
struct Created;
struct Verified;
struct Approved;
struct Executed;
struct Transaction<T> {
amount: u64,
_marker: PhantomData<T>,
}
impl Transaction<Created> {
fn verify(self) -> Transaction<Verified> {
println!("Verified transaction: {}", self.amount);
Transaction { amount: self.amount, _marker: PhantomData }
}
}
impl Transaction<Verified> {
fn approve(self) -> Transaction<Approved> {
println!("Approved transaction: {}", self.amount);
Transaction { amount: self.amount, _marker: PhantomData }
}
}
impl Transaction<Approved> {
fn execute(self) -> Transaction<Executed> {
println!("Executed transaction: {}", self.amount);
Transaction { amount: self.amount, _marker: PhantomData }
}
}Go
type Created struct{}
type Verified struct{}
type Approved struct{}
type Executed struct{}
type Transaction[T any] struct {
Amount uint64
_ T
}
func Verify(tx Transaction[Created]) Transaction[Verified] {
fmt.Println("Verified transaction:", tx.Amount)
return Transaction[Verified]{Amount: tx.Amount}
}
func Approve(tx Transaction[Verified]) Transaction[Approved] {
fmt.Println("Approved transaction:", tx.Amount)
return Transaction[Approved]{Amount: tx.Amount}
}
func Execute(tx Transaction[Approved]) Transaction[Executed] {
fmt.Println("Executed transaction:", tx.Amount)
return Transaction[Executed]{Amount: tx.Amount}
}Why This Is Powerful
- Writing to a read-only file -> only allow
Write(f File[Writable]) - Sending an unverified request -> only allow
Send(r Request[Validated]) - Applying wrong permissions -> distinct phantom markers for each permission
- Unsafe API transitions -> type-safe state machines
Rust vs Go: Comparing the Experience
- Native support: Rust yes (
PhantomData), Go no (generics simulate it) - Zero-cost abstraction: both yes
- Compile-time enforcement: both strong
- Ergonomics: Rust cleaner, Go slightly more explicit with marker fields
Final Thoughts
Phantom types might seem invisible, but their impact is huge. They turn "just data" into intent-driven, safely handled structures.
Whether you are working in Rust or Go, phantom types can dramatically improve safety, readability, and correctness.