Skip to content
beginner Phase 6 · Structs, Traits, and Trait Bounds

Structs and Field Syntax

Group related data with named, tuple, and unit struct definitions.

40m
0 problems
Topic Progress 0%

Named Fields, Shorthand, and Update Syntax

A struct is the tool for "these values belong together" — the same job a class does in an object-oriented language, minus inheritance and minus hidden behavior. The everyday form names every field:

struct User {
    id: u64,
    name: String,
    active: bool,
}

let ada = User {
    id: 1,
    name: String::from("Ada"),
    active: true,
};

println!("{} #{}", ada.name, ada.id);

Construction is a struct literal: the type name, then every field with a value. Every field, every time — there are no default values and no partially-filled literals, so a missing or misspelled field is a compile error that names the field list. That strictness pays off immediately in refactors: adding a field to a struct breaks every construction site, and the compiler hands you the checklist.

When a local variable has the same name as its field, the field init shorthand removes the repetition:

fn new_user(id: u64, name: String) -> User {
    let active = true;                 // local matches the field name
    User { id, name, active }          // shorthand: `id` means `id: id`
}

Update syntax builds a new value from an existing one with ..:

let ada2 = User { name: String::from("Ada L."), ..ada };

Read it as "the rest comes from ada" — and mind the ownership fine print: fields moved into the new literal are gone from the old value, while Copy fields (like id and active) are copied. After that line, ada.name is no longer usable, which surprises exactly once and then becomes second nature. Note also that update syntax copies fields, not the struct — there is no cloning involved unless the moved fields' types clone themselves.

Fields are private by default to the module that declares them, and that is a design lever rather than an annoyance: keep fields private and expose behavior through methods — impl Blocks and Methods is next so you can validate, log, or change representation without breaking callers. A struct with all-private fields and a constructor function is the standard Rust shape for "invariant-bearing value" — the type system, not a comment, enforces that nobody constructs an invalid one.

Visibility is per field, so mixed designs are normal — an id you are happy to expose next to a balance you are not:

pub struct Account {
    pub id: u64,          // anyone may read the id
    balance: f64,         // module-private: mutations go through methods
}

This granularity is the answer to "why are there no getters in Rust?": a field can be readable without being writable, and a field that must not be read raw simply stays private. Nothing is exposed "for convenience" unless you write pub on that specific field.

Two habits complete the named-field toolkit. First, derive the standard traits the moment a struct represents a value rather than a behavior: #[derive(Debug, Clone, PartialEq)] gives you {:#?} pretty-printing for tests, cheap clones, and assert_eq! comparisons — the three things you will want within a day of defining the type. Second, remember that the literal syntax works in pattern position too, which ties this topic directly to what you learned in Destructuring and Nested Patterns:

match account {
    Account { id, balance } if balance < 0.0 => println!("{id} overdrawn"),
    Account { id, .. } => println!("{id} healthy"),
}

The same field list appears in construction and in matching, so learning one syntax teaches both.

Tuple Structs and Unit Structs: Newtypes and Markers

A tuple struct gives a name to what would otherwise be an anonymous pair of types. It is still a real, distinct type:

struct Meters(f64);
struct Seconds(f64);

fn pace(distance: Meters, time: Seconds) -> f64 {
    distance.0 / time.0.0        // positional access through .0, .1, ...
}

let d = Meters(400.0);
let t = Seconds(48.5);
// pace(400.0, 48.5)            // error: expected `Meters`, found `f64`
let wrong = Seconds(400.0);      // compiles — but pace(d, wrong) is still an error

That middle line is the entire point. A type F64 = f64; alias would change nothing — aliases are transparent, and any f64 flows anywhere. A tuple struct creates a new type, so the compiler can catch "passed distance where time belongs", a bug class that costs real money in navigation and trading code. This is the newtype pattern: zero runtime cost (the wrapper adds no memory or indirection), complete type-level distinction. It also serves the opposite direction — wrapping a foreign type to give it local traits, which is the only way to satisfy the orphan rule you will meet in Traits and Implementations.

Tuple struct fields are still fields: they can be private (struct Secret(String) where .0 is unreachable outside the module), they take part in moves and borrows like any other field, and destructuring works with positional patterns: let Meters(d) = distance;.

A unit struct has no fields at all:

struct Marker;
let m = Marker;           // one value, zero bytes

It occupies zero memory (size_of::<Marker>() == 0) and exists to carry a name in the type system: trait implementations for marker types, configuration tags (struct Json; vs struct Csv; to select serializers at compile time), and — most often in real code — PhantomData<Marker> to give a generic struct an ownership or variance signal without storing a value. Unit structs are easy to underrate; in an API, a marker type beats a boolean flag because impossible combinations fail to compile instead of failing at run time.

Concretely, marker types select behavior at compile time:

struct Json;
struct Csv;

trait Encode { fn encode(&self, rows: &[Row]) -> String; }
impl Encode for Json { /* ... */ }
impl Encode for Csv  { /* ... */ }

fn export<E: Encode>(fmt: &E, rows: &[Row]) -> String { fmt.encode(rows) }

let text = export(&Json, &rows);   // the *type* chooses the format

Call export(&Csv, ..) and a different implementation runs — no boolean flag, no stringly-typed format argument, no chance of passing format = "json" and getting CSV. And PhantomData<T> is how the standard library itself uses markers: Vec<T>'s definition contains a PhantomData<T> so the type system knows the vector owns Ts it cannot see in the raw-pointer fields — controlling drop-check, variance, and auto-traits like Send without spending a byte of memory.

A last piece of syntax worth knowing: tuple struct fields can carry visibility individually, which is how you expose a read-only view of a wrapper's contents without offering construction control:

pub struct Meters(pub f64);     // constructible and readable anywhere
pub struct Secret(f64);         // opaque: only this module touches .0

pub struct Meters(pub f64) — public type, public field — is the quick-and-honest wrapper; Secret(f64) with no pub on the field is the opaque one that will grow methods next week. Choosing between them is choosing whether the representation is part of your API today.

Choosing among the three forms is a naming decision about intent: named fields when values are read by name at use sites (most domain data), tuple structs when the values are positional and the type name is doing the work (measurements, newtypes, wrappers), unit structs when only the name matters (markers, tags, zero-sized state carriers).

Ownership Rules That Shape Every Struct Design

Structs inherit the ownership model wholesale, and five of its rules actively shape how you design types.

Every field is owned by the struct — unless the field is a reference, in which case the borrowing rules attach to that field's own lifetime, as covered in Holding References in Structs. The design consequence is immediate: a struct holding a String owns its data and can be moved, cloned, and stored anywhere; a struct holding a &str drags a lifetime parameter into every signature above it. Teams converge on owning data at module boundaries and borrowing only inside short-lived views.

Borrows are tracked per field, not per struct. The checker sees paths such as p.header, so two mutable borrows of different fields coexist:

struct Packet { header: Vec<u8>, body: Vec<u8> }

fn fill(p: &mut Packet) {
    let h = &mut p.header;
    let b = &mut p.body;      // OK: disjoint paths, not two borrows of `p`
    h.push(1);
    b.push(2);
}

Only a borrow of the whole value — &p or &mut p — conflicts with a field borrow, which is why passing &mut packet.header to a helper while you keep using the rest of the packet is legal. The design rule that follows: prefer several focused fields over a single inner: Blob sub-struct, because folding everything into one field merges those paths back into one and the compiler can no longer tell your intent apart.

Moving out of fields is partial, not all-or-nothing. You can move individual fields; the remaining fields stay usable:

struct Package { label: String, weight: u32 }

let p = Package { label: String::from("crate"), weight: 12 };
let label = p.label;        // move: `p.label` is uninitialized from here on
let weight = p.weight;      // u32 is Copy, so this copies — p.weight still valid
println!("{weight}");       // ok
println!("{}", p.label);    // error: use of a field after it left
error[E0382]: borrow of partially moved value: `p`
  |
8 |     println!("{}", p.label);
  |                    ^^^^^^^ value borrowed here after partial move

The word partially is doing the work: the compiler knows p.weight is intact and keeps letting you use it, while whole-struct uses — passing p by value, borrowing p as a whole — are rejected until the moved field is reinitialized. The same tracking works at depth, so moving p.header.label marks exactly that path, and one destructuring let Package { label, weight } = p; moves several fields in a single statement: the pattern form of the rule you already met in Partial Moves and Field Ownership.

One exception blocks partial moves: Drop. A type with a destructor is disposed of as a whole, so the compiler forbids moving fields out of it:

let c = Connection { socket, label };
let label = c.label;   // error[E0509]: cannot move out of type `Connection`,
                       //             which implements the `Drop` trait

The escapes are deliberate, because each one visibly reinitializes the field: std::mem::replace(&mut field, stand_in) swaps in a placeholder, and an Option<T> field offers take():

struct Session { token: Option<String> }

let mut session = Session { token: Some(String::from("t1")) };
let old = session.token.take();   // moves the String out, leaves `None` behind
assert!(old.is_some() && session.token.is_none());

take() works precisely because the field is never actually empty — the destructor still has a complete value to run on. When neither fits, restructure so the droppable parts live in a nested struct you can move out entirely.

Drop order itself is a rule worth stating. When a struct goes out of scope, its own drop method runs first — with every field still intact — and then fields are dropped in declaration order. That is the opposite of C++, and it is a real constraint: a struct holding a MutexGuard next to the data it protects releases resources in the order you declared them, so field order in such a struct is a design decision rather than formatting.

Every rule above says what a struct is. impl Blocks and Methods is where you decide what it does — and where you stop handing raw fields to callers.

Quiz

1. What does the update syntax `User { name: n, ..old }` do, and what is its ownership cost?

Question 1 options

2. How does `struct Meters(f64);` differ from `type Meters = f64;`?

Question 2 options

3. Why can't you write `let label = c.label;` when `Connection` implements `Drop`?

Question 3 options

4. In what order are a struct's fields dropped?

Question 4 options

Flashcards

Question

What are the three struct forms, and when do you use each?

Answer

Named-field structs (`struct User { id: u64 }`) for domain data read by name; tuple structs (`struct Meters(f64)`) for positional values where the type name carries the meaning (newtypes, measurements); unit structs (`struct Marker;`) for zero-sized marker/tag types and PhantomData carriers.

Question

Explain field-init shorthand and update syntax, including what moves.

Answer

`User { id, name }` is shorthand for `User { id: id, name: name }` when a local matches the field name. `User { active: false, ..old }` fills omitted fields from `old`: `Copy` fields are copied, owned fields are moved out, leaving `old` partially moved. No deep clone occurs.

Question

Newtype vs type alias: what does each do to the type system?

Answer

`struct Meters(f64);` creates a distinct, checked type — the compiler rejects mixing it with raw `f64` or `Seconds`, at zero runtime cost. `type Meters = f64;` is only a different spelling of `f64`; values are fully interchangeable and no checking is added.

Question

What is field visibility, and what is the standard design it enables?

Answer

Struct fields are private to their module by default (`pub` opens them). The standard design: private fields plus constructor functions or methods, so invariants are enforced at construction and representation can change without breaking callers.

Question

When are partial moves out of a struct allowed, and when are they forbidden?

Answer

Allowed for ordinary structs: moving an owned field leaves the rest usable, and `Copy` fields are copied. Forbidden (E0509) when the struct implements `Drop`, because the destructor needs all fields; use `mem::replace`, `Option::take`, or restructure. Fields drop in declaration order.

Revision Notes

Key Takeaways

  • 1. Struct literals require every field, so the compiler turns construction sites into a refactor checklist whenever a struct grows
  • 2. Field-init shorthand and `..update` exist to remove repetition — and update syntax moves owned fields out of its source
  • 3. A tuple struct is a distinct checked type at zero cost; a type alias is only a synonym — that difference is the newtype pattern
  • 4. Fields are module-private by default; private fields plus invariant-enforcing constructors is the standard safe design
  • 5. Partial moves work on plain structs but are forbidden on `Drop` types, and fields are dropped in declaration order

Interview Tips

  • • "What does `..old` do?" — fills omitted fields by moving owned values and copying `Copy` ones; state clearly the source is partially moved and no clone happens
  • • "Why use a newtype instead of a type alias?" — only the newtype creates a distinct type the compiler checks; mention zero runtime cost and the orphan-rule workaround role
  • • "When can't you move a field out of a struct?" — when the struct implements `Drop` (E0509); give the `mem::replace`/`Option::take` workarounds
  • • "How do you keep struct invariants?" — private fields by default, constructor functions that validate, methods for mutation; show you treat visibility as API design

Cheat Sheet

Structs and Field Syntax

Three forms:

Form Example Role
named-field struct User { id: u64, name: String } domain data, read by name
tuple struct Meters(f64); newtype / positional wrapper
unit struct Marker; zero-sized tag, trait carrier

Construction:

let u = User { id, name };                    // field-init shorthand
let v = User { active: false, ..u };          // update: Copy → copy, owned → move
let m = Meters(400.0); let Meters(d) = m;     // newtype build / destructure

Rules to remember:

  • Every field listed in the literal; missing/unknown fields are compile errors (the refactor checklist)
  • Fields private to the module by default — pub is a decision
  • Update syntax moves owned fields out of the source; the source is partially moved after
  • Partial moves allowed on plain structs; blocked with E0509 when the struct has Drop
  • Drop fields release in declaration order
  • Newtypes cost zero bytes and add real type distinction; aliases add nothing

Design default: private fields + constructor enforcing invariants; owned data at API boundaries, borrowed fields for short-lived views.

Continue Learning