When a name matters but labels don't
You are building a configuration system. You need to store a timeout duration and a port number. Both are integers. You could use u32 for both. That works until you accidentally pass the port where the timeout belongs. The compiler sees u32 and u32 and shrugs. The bug hides until runtime.
You could create a regular struct with named fields: struct Config { timeout: u32, port: u32 }. That fixes the type safety, but now you have to write config.timeout everywhere. You rarely care about the name. You just want to group the values and keep them distinct from other integers. The named fields add noise without value.
You need a type that has a name, carries meaning, and prevents accidental swaps, but keeps the positional simplicity of a tuple. That is a tuple struct. It gives you a custom type identity with the ergonomics of a grouped list.
Concept: A named tuple
A tuple struct is a struct where the fields are accessed by index, not by name. You define it with a name and a list of types in parentheses. You construct it like calling a function. You access fields using dot notation with numeric indices, starting at zero.
Think of a tuple struct like a train car. The car has a name, like "Express 42". Inside, the seats are numbered 1, 2, 3. You don't label each seat "Window Seat" or "Aisle Seat". You access them by number. The car name tells you what the container is. The numbers tell you where the data lives.
Tuple structs are distinct types. Color(u8, u8, u8) is not the same type as (u8, u8, u8). The compiler treats them as completely separate entities. You cannot pass a tuple where a tuple struct is expected, and vice versa. This distinction is the whole point. The name creates a boundary that keeps your data safe from misuse.
Treat the name as a contract. The fields are just the payload.
Minimal example
Here is the syntax. You define the struct with struct Name(Type1, Type2). You create an instance by writing Name(value1, value2). You access fields with .0, .1, and so on.
/// Represents a 2D point with x and y coordinates.
struct Point(f64, f64);
fn main() {
// Construct the point like a function call.
let origin = Point(0.0, 0.0);
// Access fields by index.
println!("X: {}", origin.0);
println!("Y: {}", origin.1);
}
The code compiles and runs. Point is now a type you can pass around. The compiler knows origin is a Point, not a generic pair of floats.
What happens under the hood
At compile time, the compiler registers Point as a unique type. It checks that you provide the correct number and types of arguments when you construct it. It checks that you access valid indices when you read fields. If you try to access origin.2, the compiler rejects the code immediately. Tuple structs have a fixed size defined at creation.
At runtime, a tuple struct has zero overhead compared to a plain tuple. The memory layout is identical. The name exists only in the type system. There is no string stored in memory saying "Point". The compiler uses the type to enforce correctness, then optimizes it away. You get type safety for free.
Realistic usage: The newtype pattern
The most common use of tuple structs is the newtype pattern. You wrap a primitive or existing type to create a new type with specific semantics. This prevents mixing up values that share the same underlying representation but mean different things.
Consider a user ID. It is just a number. But you should never pass a user ID where a password hash length is expected. Wrapping it in a tuple struct makes that mistake impossible.
/// Wraps a user ID to prevent accidental misuse.
struct UserId(u32);
/// Wraps a password hash length.
struct HashLength(u32);
fn create_user(id: UserId, hash_len: HashLength) {
// Use the inner values for logic.
println!("Creating user {} with hash length {}", id.0, hash_len.0);
}
fn main() {
let id = UserId(42);
let len = HashLength(64);
// This compiles. The types match.
create_user(id, len);
// This fails at compile time.
// create_user(id, id);
// Error: mismatched types (E0308)
// expected `HashLength`, found `UserId`
}
The newtype pattern is a community standard for safety. You wrap primitives like u32, String, or Vec<T> to give them domain-specific meaning. The wrapper isolates the data and lets you add methods later without changing the underlying type.
Wrap your primitives. Your future self will thank you when you stop passing a port number where a user ID belongs.
Methods and destructuring
Tuple structs can have methods, just like regular structs. You implement them in an impl block. Inside methods, you access fields by index. You can also destructure tuple structs to extract fields into named variables. This is useful when you need to work with the components individually.
/// A color in RGB format.
struct Color(u8, u8, u8);
impl Color {
/// Returns the brightness as a value between 0 and 1.
fn brightness(&self) -> f32 {
// Access fields by index inside methods.
let sum = self.0 as f32 + self.1 as f32 + self.2 as f32;
sum / (3.0 * 255.0)
}
}
fn main() {
let red = Color(255, 0, 0);
// Call the method.
println!("Brightness: {}", red.brightness());
// Destructure to get named variables.
let Color(r, g, b) = red;
println!("Components: r={}, g={}, b={}", r, g, b);
}
Destructuring matches the definition order. let Color(r, g, b) assigns the first field to r, the second to g, and the third to b. If the order is wrong, the compiler catches it. Destructuring is idiomatic when you need to inspect or modify multiple fields at once.
Pitfalls and compiler errors
Tuple structs have a few traps for the unwary. Most are caught by the compiler, but understanding them saves time.
If you try to access a field by name, the compiler rejects you with E0609 (no field). Tuple structs do not have named fields. You must use indices.
struct Point(f64, f64);
fn main() {
let p = Point(1.0, 2.0);
// Error: no field `x` on type `Point` (E0609)
// println!("{}", p.x);
}
If you try to mutate a field on an immutable binding, you get E0596 (cannot assign twice to immutable variable). You need let mut to modify fields.
struct Counter(u32);
fn main() {
let mut c = Counter(0);
// This works because c is mutable.
c.0 += 1;
// Without `mut`, this fails with E0596.
}
Indexing is checked at compile time. Accessing an out-of-bounds index fails immediately. There is no runtime panic for valid code. The compiler knows the struct has three fields. color.3 is a hard error.
Trust the index check. If color.2 compiles, the field exists.
Convention asides
The community has a few conventions around tuple structs.
Single-field tuple structs are often called "newtypes". This term refers to the pattern of wrapping a type to create a new one. When you see struct UserId(u32), readers recognize it as a newtype immediately.
For single-field tuple structs used in FFI, add #[repr(transparent)]. This tells the compiler to guarantee the memory layout matches the inner type. It allows you to cast between the wrapper and the inner type safely when calling C code. This is a performance and compatibility optimization.
When deriving traits like Debug or PartialEq, tuple structs work normally. #[derive(Debug)] prints the struct name and fields in parentheses. This is the expected output format.
Decision: When to use tuple structs
Use a tuple struct when you want to wrap a value to create a new type identity, like a UserId(u32) or Celsius(f32). Use a tuple struct when you have a fixed group of values that are always accessed by position, such as RGB components or 2D coordinates. Use a regular struct when your fields have meaningful names that you reference by name in the code, like User { name, age }. Use a plain tuple when the grouping is local to a function and doesn't need to cross module boundaries with a specific type name.