When a type needs to exist but hold nothing
You're building a logging library. You want to let users choose a strategy: print to stdout, write to a file, or panic on error. You could use an enum. You could use a boolean flag. But you want the compiler to enforce the strategy at the type level. You want a function that only accepts the "panic" strategy, and you want that restriction visible in the signature. You don't need to store a file handle or a buffer for the panic strategy. The behavior is hardcoded. You just need a type that says "I am the panic strategy."
Rust gives you a tool for this: the unit struct. It's a struct with no fields, declared with a semicolon. It carries zero data, takes zero bytes, and exists purely to provide a unique identity in the type system. It's the smallest possible type in Rust, and it's the standard way to create compile-time flags, marker types, and lightweight error variants.
The concept: a flag in the type system
Think of a unit struct as a type-level token. In runtime code, a flag is a boolean you check inside an if statement. In the type system, a unit struct is a flag the compiler checks before your code runs. It's a way to say "This type exists" without saying "This type holds data."
Unit structs are Zero-Sized Types, or ZSTs. The compiler knows they occupy no memory. You can pass them around, store them, or drop them, and the cost is nothing. Two instances of a unit struct are indistinguishable. There is no state to compare. The value exists solely to satisfy type constraints.
This distinction matters. A unit struct is not a singleton. You can create as many instances as you want. They are all identical. If you need a single shared instance with state, you need a different mechanism like std::sync::OnceLock. A unit struct limits nothing about instantiation. It only provides a unique name for the type.
Minimal example
Here is a unit struct used as a marker to enable a feature. The function accepts only the marker type, so the compiler enforces that the caller has opted in.
/// Marker type to signal debug capability at the type level.
/// No fields are needed; the type itself is the signal.
struct DebugMode;
/// Function accepts only the DebugMode marker.
/// The compiler rejects calls that don't provide this specific type.
fn enable_debug(_marker: DebugMode) {
println!("Debug features unlocked");
}
fn main() {
// Creating a unit struct instance requires no arguments.
// The value is zero-sized and carries no information.
let _token = DebugMode;
enable_debug(_token);
}
The DebugMode struct has no fields. You create an instance by writing the name. The compiler knows the type, checks the signature, and allows the call. If you try to call enable_debug with anything else, the compiler rejects it. The feature is gated by the type, not a runtime check.
How the compiler treats unit structs
When you write struct DebugMode;, you define a new type. The semicolon terminates the definition. There are no braces, no fields, and no tuple parentheses. When you write DebugMode in an expression, you get an instance of that type.
Since there are no fields, the instance has no state. The memory footprint is zero. The compiler optimizes away any storage for the value. You can put a unit struct in a vector, and the vector's data allocation takes no space. Only the vector's header (pointer, length, capacity) exists.
struct Marker;
fn main() {
// A vector of a million markers.
// The allocation for elements is zero bytes.
// Only the vector header is stored.
let markers = vec![Marker; 1_000_000];
println!("Length: {}", markers.len());
}
This isn't just a curiosity. It means you can use unit structs as markers in collections without paying for storage. You can track "which types support feature X" by storing markers in a list, and the list consumes no heap memory for the elements. The optimization is automatic. The compiler knows the size is zero and skips allocation.
Realistic example: strategy markers
Unit structs shine when you need to select behavior at compile time. Here is a pattern where unit structs act as strategy markers for error handling. Each strategy is a distinct type with no state. The behavior is implemented in the trait.
use std::fmt;
/// Trait for error reporting strategies.
/// Implementors define how errors are handled.
trait ErrorReporter {
fn report(&self, msg: &str);
}
/// Unit struct representing a silent error strategy.
/// No state needed; the behavior is hardcoded to do nothing.
struct SilentReporter;
impl ErrorReporter for SilentReporter {
fn report(&self, _msg: &str) {
// Intentionally does nothing.
// The unit struct encapsulates the "do nothing" policy.
}
}
/// Unit struct for a panic-on-error strategy.
struct PanicReporter;
impl ErrorReporter for PanicReporter {
fn report(&self, msg: &str) {
panic!("Fatal error: {}", msg);
}
}
/// Generic function that uses a reporter.
/// The type R determines behavior at compile time.
fn process_with_reporter<R: ErrorReporter>(reporter: R) {
reporter.report("Something went wrong");
}
fn main() {
// Pass the unit struct instance to select behavior.
// The compiler monomorphizes the function for SilentReporter.
process_with_reporter(SilentReporter);
// Uncommenting this would panic at runtime.
// process_with_reporter(PanicReporter);
}
The SilentReporter and PanicReporter are unit structs. They carry no data. The trait implementation defines the behavior. The generic function accepts any type that implements the trait. The caller selects the strategy by passing the unit struct instance. The compiler generates a specialized version of the function for each strategy. There is no runtime dispatch. The cost of the abstraction is zero.
Pitfalls and compiler errors
Unit structs are simple, but they have traps. The most common mistake is assuming they are automatically copyable. Unit structs are not Copy by default. If you pass one by value, it moves. You get E0382 (use of moved value) if you try to use it again.
struct Token;
fn main() {
let t = Token;
consume(t);
// t is moved here.
// consume(t); // Error E0382: use of moved value.
}
fn consume(_t: Token) {}
The fix is to derive Copy and Clone. Unit structs used as markers almost always derive these traits. If you forget, the compiler treats the marker like a unique resource, which defeats the purpose of a stateless flag.
#[derive(Clone, Copy)]
struct Token;
Another pitfall is confusing syntax. Unit structs use a semicolon. Tuple structs use parentheses. Regular structs use braces. If you define a unit struct and try to construct it with parentheses, the compiler rejects you with an error saying the struct takes no arguments.
struct Unit;
fn main() {
// This fails. Unit structs do not use parentheses.
// let _x = Unit(); // Error: this function takes 0 arguments but 1 was supplied.
// Correct construction.
let _x = Unit;
}
The community convention is to use struct Name; for unit types. You rarely see struct Name {}. The semicolon signals "no fields" immediately. If you see braces, expect fields or a deliberate choice to allow future expansion, though adding fields still breaks the API. Stick to the semicolon.
Unit structs are also not the right tool for singletons. If you need a single shared instance, a unit struct doesn't help. You can create infinite instances. If you need shared state, use std::sync::OnceLock or a crate like lazy_static. A unit struct is just a type. It doesn't limit instantiation.
Decision: when to use unit structs
Use a unit struct when you need a unique type identity with zero data, such as a marker for trait implementations or a compile-time flag. Use a tuple struct when you need to wrap a single value for type safety, such as the newtype pattern around a String or i32. Use a regular struct when you need to store multiple named fields or when the data has a complex layout that benefits from named access. Reach for an enum when you have a set of variants, even if some variants carry no data; enums group related options better than scattered unit structs and provide exhaustiveness checking.
Treat the unit struct as a compile-time flag. If it holds data, it's the wrong tool. Derive Copy or move semantics will bite you. Zero size does not mean zero cost of reasoning. The type still matters.