The data mold
You are building a configuration parser. You need to group a hostname, a port number, and a timeout value together so they travel as a single unit through your code. In JavaScript, you would create an object literal. In Python, you might reach for a dictionary or a dataclass. Rust gives you a struct. It is the fundamental building block for grouping related data, but it works differently than the object-oriented classes you might be used to. A struct in Rust is purely a data container. It does not carry methods, inheritance, or hidden state. You attach behavior later, and only if you need it.
Think of a struct definition as a stencil. The stencil defines the exact shape and size of the data. It does not contain the data itself. When you create an instance, you are pressing that stencil onto a sheet of memory and filling in the blanks. The compiler uses the stencil to reserve exactly the right amount of contiguous space. No hidden vtables, no dynamic dispatch overhead, no garbage collection metadata. Just the fields, laid out side by side.
This separation of data and behavior is intentional. Rust treats data as the primary citizen. You define what the data looks like first. You decide later whether it needs to do anything. This keeps your types lean and makes it obvious exactly what memory you are moving around. Define the shape clearly. Attach behavior only when it belongs to that shape.
How memory actually works
When the compiler sees a struct definition, it calculates the total size immediately. A String is 24 bytes on a 64-bit system. It holds a pointer to the heap, a length, and a capacity. A u16 is 2 bytes. A u32 is 4 bytes. The compiler adds padding between fields to satisfy CPU alignment requirements, usually landing the total size on a multiple of 8 or 16 bytes. This layout is fixed at compile time. The compiler knows exactly how many bytes to push onto the stack or copy into a function call.
This predictability is why Rust structs feel fast. There is no indirection unless you explicitly put a pointer in the struct. There is no runtime type information attached to the instance. The struct is exactly what you declared, nothing more. When you pass a struct to a function, the compiler generates code to copy those exact bytes. If the struct is large, the compiler will often optimize the copy away or pass it by reference automatically. You do not need to guess. The layout is deterministic.
Treat the struct definition as a contract with the compiler. You promise the fields and their types. The compiler promises the memory layout and zero-cost access.
Minimal example
/// Represents a server connection configuration
struct ServerConfig {
host: String,
port: u16,
timeout_ms: u32,
}
fn main() {
// Allocate fields according to the struct layout
let config = ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
timeout_ms: 5000,
};
// Read fields directly via dot notation
println!("Connecting to {} on port {}", config.host, config.port);
}
Notice the let keyword. Without mut, config is immutable. You cannot change config.port later. The compiler enforces this at the binding level, not the field level. If you need to modify the data, you declare the binding as mutable: let mut config = .... This is a deliberate design choice. Immutability is the default because it eliminates entire classes of race conditions and accidental state corruption. You opt into mutation only when you explicitly need it.
Trust the default immutability. Make bindings mutable only when the algorithm requires state changes.
Attaching behavior and enforcing rules
Real code rarely uses raw struct literals everywhere. You want to enforce rules before the struct exists. You want to attach functionality that operates on the data. Rust uses impl blocks for this. An impl block is a namespace that attaches methods to a type. It does not change the struct definition. It just adds functions that take the struct as their first argument.
/// Represents a validated server configuration
struct ServerConfig {
host: String,
port: u16,
timeout_ms: u32,
}
impl ServerConfig {
/// Creates a new config, rejecting invalid ports or timeouts
fn new(host: &str, port: u16, timeout_ms: u32) -> Result<Self, String> {
if port == 0 {
return Err("Port cannot be zero".to_string());
}
if timeout_ms == 0 {
return Err("Timeout must be greater than zero".to_string());
}
Ok(Self {
// Convert to owned String only after validation passes
host: host.to_string(),
port,
timeout_ms,
})
}
/// Calculates the deadline timestamp based on current time
fn deadline(&self) -> u32 {
// Borrow self immutably to read fields without taking ownership
0 + self.timeout_ms
}
}
fn main() {
// Constructor enforces invariants before allocation
let config = ServerConfig::new("127.0.0.1", 8080, 5000).unwrap();
// Update syntax copies fields from an existing instance
let updated_config = ServerConfig {
host: String::from("192.168.1.50"),
..config
};
println!("New deadline: {}", updated_config.deadline());
}
The new function is not a special language feature. It is just a regular function that happens to live inside an impl block and return Self. Self is a type alias for the struct being implemented. It saves you from typing the full name repeatedly. The function takes &str instead of String to avoid forcing the caller to allocate. It converts to String internally only after validation passes. This is a standard Rust pattern: accept references, own the data.
The ..config syntax is called struct update syntax. It tells the compiler to copy the remaining fields from config into the new instance. This is where ownership rules bite you. If config contains owned types like String, the update syntax moves those values into the new struct. The old config becomes partially initialized and unusable. If you need to keep the original, you must clone the fields explicitly or redesign the function to take references.
Methods like deadline take &self as their first parameter. This is equivalent to writing self: &Self. It means the method borrows the struct immutably. You can call it on immutable or mutable bindings. You can call it multiple times concurrently. The compiler knows the method does not modify the data.
Keep impl blocks focused. Put related methods together. Separate public constructors from internal helpers. The compiler does not care about organization, but your future self will.
Where beginners trip
Structs trip up developers in predictable ways. The compiler will catch them, but the error messages require a shift in mindset.
If you try to modify a field without let mut, the compiler rejects you with E0596 (cannot assign to immutable variable). The fix is not to make the field mutable. The fix is to make the binding mutable. Rust ties mutability to the variable, not the type. You declare mutability at the point of creation, not at the point of access.
If you use struct update syntax on a struct containing String or Vec, you will trigger E0382 (use of moved value). The original variable is now in a limbo state. Its heap data has been transferred to the new struct. If you need both, clone the owned fields before the update, or redesign the function to take references.
If you forget a field during initialization, you get E0063 (missing fields). Rust does not allow partial initialization. Every field must be assigned. If you are building a struct step by step, use a builder pattern or initialize with placeholder values like String::new() or 0.
If you pass a u32 where a u16 is expected, the compiler stops you with E0308 (mismatched types). Rust does not perform implicit numeric conversions. You must write as u16 explicitly. This prevents silent truncation bugs that plague C and JavaScript.
Convention aside: always derive Debug for your structs. Add #[derive(Debug)] above the definition. It gives you a free {:?} formatter. You will spend more time printing structs for debugging than you expect. Skipping this step is a tax you pay later. Also, use Self in impl blocks instead of repeating the struct name. It reduces noise and makes refactoring safer.
Read the error code. The compiler tells you exactly which rule you broke. Fix the rule, not the symptom.
Picking the right shape
Rust provides three struct flavors. Pick the right one based on your data shape and how you plan to use it.
Use regular named structs when your data has clear, semantic labels. Named structs are self-documenting. User { name: "Alice", id: 42 } reads like English. Use them for configuration, domain models, and any data that will be passed across module boundaries. Named structs survive refactoring better because field names are explicit.
Use tuple structs when the fields have no meaningful names, or when you need to distinguish types that share the same underlying data. struct Color(u8, u8, u8) and struct Point(f32, f32) keep the compiler from mixing up RGB values with coordinates. Tuple structs are also the foundation for newtype wrappers, which you will use heavily for type safety and zero-cost abstractions.
Use unit structs when you need a type marker but no data. struct DatabaseError; or struct Uninitialized; serve as tokens for trait implementations or state machines. They take zero bytes at runtime. Use them to tag types for compile-time checks, not to hold values.
Reach for named structs by default. Switch to tuple or unit structs only when the data shape or compile-time guarantees demand it.