Post

Structs: Building Custom Types with Custom Invariants

Structs let you define your own types — and your own invariants. By controlling construction, field access, and method behavior, you decide what 'valid' means for your data.

Structs: Building Custom Types with Custom Invariants

Primitive types carry built-in invariants: a bool is always true or false, an i32 is always a valid 32-bit integer. But real programs need domain-specific types — a Student, a Point, an Address — with domain-specific invariants. That’s what structs are for.

A struct groups related data under a single type. More importantly, when combined with Rust’s visibility rules and methods, a struct lets you define and enforce your own invariants — constraints on what constitutes valid data for your domain.

Defining a Struct

1
2
3
4
5
6
7
struct Student {
    name: String,
    age: i32,
    email: String,
    semester: String,
    blood_group: String,
}

Student is now a type. It has five fields, each with a name and a type. The struct definition is a template — it declares what data a Student must contain but doesn’t create any values.

The type invariant is immediate: a Student always has all five fields, always of the declared types. You cannot create a Student with a missing email or an age that’s a string. The compiler enforces it.

Creating Instances

Create an instance by providing values for every field:

1
2
3
4
5
6
7
8
9
fn main() {
    let student_1 = Student {
        name: String::from("Alice"),
        age: 30,
        email: String::from("alice@example.com"),
        semester: String::from("4th"),
        blood_group: String::from("AB+ve"),
    };
}

Field order doesn’t matter — you can specify them in any sequence:

1
2
3
4
5
6
7
8
9
fn main() {
    let student_1 = Student {
        email: String::from("alice@example.com"),
        semester: String::from("4th"),
        blood_group: String::from("AB+ve"),
        name: String::from("Alice"),
        age: 30,
    };
}

The invariant: every field must be initialized. There is no “partial construction.” A Student with four out of five fields is not a Student — it’s a compile error.

Field Init Shorthand

When a variable has the same name as a field, you can use the shorthand:

1
2
3
4
5
6
7
8
9
10
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let x = 10;
    let y = 20;
    let point1 = Point { x, y }; // equivalent to Point { x: x, y: y }
}

Creating Instances from Other Instances

The struct update syntax copies fields from an existing instance:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Point {
    x: i32,
    y: i32,
    z: i32,
}

fn main() {
    let x = 10;
    let y = 20;
    let z = 30;
    let p1 = Point { x, y, z };
    let p2 = Point { x: 40, ..p1 }; // y and z come from p1
}

The ..p1 syntax fills remaining fields from p1. The invariant holds: p2 still has all three fields, all correctly typed.

Tuple Structs

Structs without named fields — useful when field names would be redundant:

1
2
3
4
5
struct Address(String);

fn main() {
    let address = Address(String::from("INDIA"));
}

Tuple structs give the type a name without naming each field. Address is a distinct type from String — you cannot accidentally pass a String where an Address is expected. This is the newtype pattern, and it creates a type-level invariant: Address and String are different types with different semantics, even though they contain the same data.

Unit-Like Structs

Structs with no fields at all:

1
2
3
4
5
struct UnitLike;

fn main() {
    let subject = UnitLike;
}

These are useful for implementing traits on a type that doesn’t need to store data. The invariant is trivial — a UnitLike value always exists and is always valid.

Accessing Fields

Use dot notation for named structs:

1
2
3
4
5
6
7
8
9
10
fn main() {
    let student_1 = Student {
        name: String::from("Alice"),
        age: 30,
        email: String::from("alice@example.com"),
        semester: String::from("4th"),
        blood_group: String::from("AB+ve"),
    };
    println!("{}", student_1.name);
}

Use index notation for tuple structs:

1
2
3
4
fn main() {
    let address = Address(String::from("INDIA"));
    println!("{}", address.0);
}

The invariant: field access is always valid. You cannot access a field that doesn’t exist — the compiler rejects it. You cannot access a field at the wrong index. Type-checked, bounds-checked, at compile time.

Methods

Methods are functions defined in the context of a struct. They are declared inside an impl block:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#[derive(Debug, Clone)]
struct Student {
    name: String,
    age: i32,
    sid: String,
    blood_group: String,
}

impl Student {
    fn new(name: String, age: i32, sid: String, blood_group: String) -> Self {
        Student {
            name,
            age,
            sid,
            blood_group,
        }
    }

    fn name(&self) -> &str {
        &self.name
    }

    fn sid(&self) -> &str {
        &self.sid
    }
}

fn main() {
    let s = Student::new(
        "ALICE".to_string(),
        32,
        "UNICAL12345".to_string(),
        "A+ve".to_string(),
    );

    println!("Student Name: {:?} and Student Id: {:?}", s.name(), s.sid());
}

Associated Functions vs Methods

  • new is an associated function — called with Student::new(...), not on an instance. It’s a constructor that ensures every Student is created with all required fields. This is where you enforce construction invariants.
  • name and sid are methods — called on an instance with s.name(). Their first parameter is &self, borrowing the instance immutably.

The Constructor Invariant

The new function is critical. By making struct fields private (the default for modules) and providing a new function, you control how instances are created. This is how you enforce invariants beyond what the type system gives you:

1
2
3
4
5
6
7
impl Student {
    fn new(name: String, age: i32, sid: String, blood_group: String) -> Self {
        assert!(age > 0, "Age must be positive");
        assert!(!sid.is_empty(), "Student ID cannot be empty");
        Student { name, age, sid, blood_group }
    }
}

Now the invariant “age is positive and SID is non-empty” is enforced at every construction site. You cannot create an invalid Student. The type itself guarantees validity.

Method Self Parameters

The self parameter determines how the method borrows the instance:

ParameterMeaning
&selfImmutable borrow — reads but cannot modify
&mut selfMutable borrow — can modify the instance
selfTakes ownership — consumes the instance

These align directly with the borrowing invariants from the previous post. A method with &self guarantees it won’t mutate your data. A method with &mut self guarantees exclusive access. The invariant is encoded in the signature.

Structs as Invariant Containers

The pattern emerges: structs are not just data containers. They are invariant containers.

MechanismInvariant Enforced
Field typesEvery field holds the correct type
Required initializationNo partial construction
Private fields + constructorDomain-specific validity rules
Methods with &selfRead-only access
Methods with &mut selfExclusive mutable access
Newtype patternType-level distinction between same-shaped data

By combining struct definitions with visibility rules and impl blocks, you build types that are correct by construction. The compiler becomes your invariant checker, and invalid states become unrepresentable.


Structs are where Rust’s invariant system becomes your own. Primitive types enforce Rust’s rules. Structs enforce yours. With ownership, borrowing, and custom types, you have the tools to make invalid states impossible — not just unlikely, but structurally unrepresentable.

This post is licensed under CC BY 4.0 by the author.