Defining and Instantiating Structs

How to define named-field structs, tuple structs, and unit-like structs in Rust, create instances, use field init shorthand, struct update syntax, and understand ownership of struct data.

A struct is Rust’s way of bundling several related values into a single, named type. If you have used tuples, you already know a way to group values of different types. Structs do the same, but with a critical improvement: every piece of data gets its own name. That name makes the code self-documenting and removes any need to remember positional order when accessing fields.

Structs vs. Tuples:

Unlike a tuple, where you access values by index (tuple.0, tuple.1), a struct lets you use meaningful field names like user.email. This difference becomes essential as the data grows beyond two or three fields.

Defining a Named-Field Struct

A struct definition introduces a new type by listing its fields and their types inside curly braces. The keyword struct starts the definition, followed by a name in PascalCase and a block of fields.

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

This definition creates a blueprint. No memory is allocated yet — it is simply a description of the shape that any User value will have.

Structs exist because real-world data comes in clusters. A user account isn’t just a username and a password in isolation; those pieces travel together. A struct gives them a shared home, and each field’s name answers the question “what is this value?” without forcing the reader to infer it from position or documentation.

A beginner can picture a struct as a paper form: every blank has a label (username, email, …), and filling out the form creates a concrete instance of that form’s template. The compiler checks that you provide exactly the right types for every blank.

Creating Instances

An instance is a concrete value built from a struct definition. You write the struct name, then curly braces containing field_name: value pairs. The order of the fields does not matter — you can list them in any sequence you like.

let user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};

Rust requires that every field is initialized. There are no default values unless you explicitly provide them in the instantiation expression. The compiler will reject any attempt to create an instance with missing fields.

Compile-Time Completeness:

If your code compiles, every struct field is guaranteed to have a valid value. There is no null and no uninitialized memory for struct fields. This eliminates an entire class of runtime bugs.

When you build an instance inside a function, you can return it implicitly by writing the struct expression as the last expression without a semicolon. The fields can draw their values from function parameters, local variables, or literal constants.

fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

Here, each email and username appears twice — once as the field name and once as the variable providing the value. That repetition is legal but can become noisy, which is exactly why Rust offers the field init shorthand.

Field Init Shorthand

When a variable or function parameter has the same name as a struct field, you can omit the : value part. Rust uses the variable’s name to match the field and takes the value from that variable.

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

The function behaves identically to the previous version. The shorthand is purely syntactic sugar — the generated code is the same. This pattern is most common in constructor-like functions where parameters mirror the struct’s fields.

Shorthand and Ownership:

The shorthand does not change how ownership works. If the field type is String, the variable is moved into the struct. After the struct is created, you cannot use the original variable unless the type implements Copy. This is the same as the explicit field: variable form; the shorthand can make the move less visually obvious, so stay aware of it.

Accessing and Modifying Fields

Dot notation reads the value of a field. If you have an instance user1, then user1.email gives you the String stored in the email field.

println!("User email: {}", user1.email);

Modifying a field requires the whole instance to be declared mutable with the mut keyword. Rust does not support per-field mutability.

let mut user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};
user1.email = String::from("anotheremail@example.com");

All-or-Nothing Mutability:

If you forget the mut on the instance, the compiler will refuse to assign to any field. A common beginner mistake is writing let user1 = ...; and then trying user1.email = ...;. The error message will mention that user1 is not declared as mutable. The fix is to add mut before the variable name.

Why does Rust require mutability on the whole binding? Because the binding is the access path to the data. If a binding is immutable, the compiler can guarantee that nothing reachable through it changes, which simplifies reasoning about the program. The only way to modify a struct’s field is through a mutable binding or a mutable reference to the struct.

Struct Update Syntax

Creating a new instance that copies most of its values from an existing one is a common pattern. You can write out every field explicitly, but Rust provides the .. update syntax to reduce repetition.

Imagine you have an existing user1 and want a new user with a different email but the same username, active status, and sign-in count. Without update syntax, you would write:

let user2 = User {
    email: String::from("another@example.com"),
    username: user1.username,
    active: user1.active,
    sign_in_count: user1.sign_in_count,
};

With update syntax, the same result takes fewer lines:

let user2 = User {
    email: String::from("another@example.com"),
    ..user1
};

The ..user1 means “fill in any remaining fields from the corresponding fields of user1.” It must appear last in the struct expression. You can override any number of fields before it, in any order.

This syntax uses a move, not a copy. In the example above, user1.username is a String that gets moved into user2. After this line, user1 as a whole can no longer be used, because a part of it has been moved. However, if you had provided new values for both username and email — leaving only active and sign_in_count to be pulled from user1 — then user1 would remain valid because those two fields are of type bool and u64, which implement the Copy trait. In that scenario, the update syntax copies the values rather than moving them.

Partial Moves and Struct Update:

After a move out of a struct via update syntax, you can still access individual fields that were not moved, but you cannot use the struct as a whole. For example, user1.email may still be accessible if only username was moved. This is a subtle point that trips up many learners — the compiler’s error messages will guide you, but it helps to know that the struct is treated as partially moved.

Tuple Structs

Sometimes naming every field adds visual noise without improving clarity. A tuple struct gives you a named type while keeping the fields anonymous, identified only by their position. The definition looks like a tuple wrapped in a struct name.

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

To create an instance, you write the struct name followed by parentheses containing the values in order:

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

Even though black and origin contain identical integer triples, they are different types. A function that expects a Color will not accept a Point. This is the primary use of tuple structs: creating distinct types that the compiler can enforce at no runtime cost, without inventing field names.

Access individual elements by index with dot notation:

let red_component = black.0;
let x_coord = origin.0;

You can destructure a tuple struct just like a regular tuple, but you must mention the type name:

let Point(x, y, z) = origin;
println!("x = {}, y = {}, z = {}", x, y, z);

Tuple structs are common in Rust APIs for wrapper types. For example, std::num::NonZeroU32 is a tuple struct that wraps a u32, guaranteeing through its constructor that the value is never zero. The type system then propagates that guarantee everywhere the wrapper is used.

Unit-Like Structs

A struct with no fields at all is called a unit-like struct. It is written with the struct keyword, a name, and a semicolon — no curly braces.

struct AlwaysEqual;

Instances are created by using the name alone:

let subject = AlwaysEqual;

A type without data might seem pointless at first glance. Its value comes when you need to implement a trait (Rust’s equivalent of an interface) for a type that holds no state. For example, you might implement a trait that defines a comparison function, and you want a type that always returns true for equality regardless of what it is compared against. You don’t need any data for that — just a type to attach the trait implementation to. Unit-like structs fill that role.

Unit-Like Structs and the Unit Type:

A unit-like struct is distinct from the unit type (). AlwaysEqual and () are two different types. The similarity is that both carry zero runtime data — they exist purely at the type level.

Ownership of Struct Data

Every field in a struct must have a known type. In the User examples, we used String (an owned, heap-allocated string) rather than &str (a borrowed string slice). That choice is deliberate: we want each User instance to own its data so that the data remains valid as long as the struct instance exists.

It is possible for a struct to hold references to data owned elsewhere, but doing so requires lifetime annotations, which tell the compiler how long those references remain valid. Without lifetimes, the compiler cannot guarantee safety and will refuse the code:

struct User {
    username: &str,  // error: missing lifetime specifier
    email: &str,
    active: bool,
    sign_in_count: u64,
}

The resulting compiler error points to the missing lifetime specifier and suggests adding one. Lifetimes are a core part of Rust’s ownership system, and they are covered in depth later. For now, the practical rule is: if your struct does not need to borrow external data, use owned types like String, Vec<T>, and plain scalar types. This keeps the struct self-contained and avoids an early confrontation with lifetimes.

Design Guideline:

A struct that owns all its data is easier to reason about and passes freely through function boundaries. When you later need to avoid allocations or share data, you can introduce references and lifetimes with a clear understanding of the trade-off.

Summary

Structs are the backbone of data modelling in Rust. A named-field struct gives each piece of data a name, making code that manipulates the data readable and maintainable. Tuple structs provide lightweight named wrappers around positional data. Unit-like structs enable trait implementations without storing state. The field init shorthand and struct update syntax remove boilerplate while preserving Rust’s ownership guarantees.

When you define a struct, you decide whether each field is owned or borrowed. Owned data keeps the struct independent; borrowed data ties it to some external lifetime. Most structs you write in early Rust programs will use owned types, because they sidestep the complexity of lifetimes until you are ready for it.