Post

Strings and String-Like Types in Rust

A concise guide Enum and Structs in Rust.

Strings and String-Like Types in Rust

Rust uses struct and enum to define custom data types. They are fundamental building blocks for modeling application state, configuration, errors, commands, and operating-system resources.

  • A struct groups related fields into one type.
  • An enum defines a type that can represent one of several variants.

1. Structs

A struct groups related data into a single value.

1.1 Named-field structs

The most common form is a struct with named fields.

1
2
3
4
5
6
struct User {
    username: String,
    email: String,
    active: bool,
    sign_in_count: u64,
}

Create an instance using field names:

1
2
3
4
5
6
7
8
9
let user = User {
    username: String::from("amir"),
    email: String::from("amir@example.com"),
    active: true,
    sign_in_count: 1,
};

println!("{}", user.username);
println!("{}", user.active);

Each field has a declared type. Every instance of User has the same fields and field types.

1.2 Mutable structs

Rust distinguishes between mutable and immutable bindings.

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

let mut point = Point { x: 10, y: 20 };

point.x = 30;
point.y = 40;

If the binding is immutable, you cannot modify its fields:

1
2
3
let point = Point { x: 10, y: 20 };

// point.x = 30; // Compile error

Field-level mutability does not exist in ordinary Rust structs. Mutability is determined by the binding, unless interior mutability types such as Cell or RefCell are used.

1.3 Field initialization shorthand

When a variable has the same name as a field, you can omit the repeated field name.

1
2
3
4
5
6
7
8
9
let username = String::from("amir");
let email = String::from("amir@example.com");

let user = User {
    username,
    email,
    active: true,
    sign_in_count: 1,
};

This is equivalent to:

1
2
3
4
5
6
let user = User {
    username: username,
    email: email,
    active: true,
    sign_in_count: 1,
};

1.4 Struct update syntax

You can create a new instance by reusing fields from an existing instance.

1
2
3
4
5
6
7
8
9
10
11
let user1 = User {
    username: String::from("amir"),
    email: String::from("amir@example.com"),
    active: true,
    sign_in_count: 1,
};

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

The fields not specified explicitly are taken from user1.

Ownership matters: if a reused field is a non-Copy value such as String, that field may be moved into the new instance. You may no longer be able to use the original instance as a whole.

1.5 Tuple structs

Tuple structs have fields without names.

1
2
3
4
5
6
7
8
9
struct Color(u8, u8, u8);

struct Coordinates(f64, f64, f64);

let red = Color(255, 0, 0);
let origin = Coordinates(0.0, 0.0, 0.0);

println!("{}", red.0);
println!("{}", origin.2);

Tuple structs are useful when you want a distinct type without named fields.

Although Color and (u8, u8, u8) have similar contents, they are different types.

1.6 Unit-like structs

A unit-like struct has no fields.

1
2
3
struct Marker;

let marker = Marker;

These can represent marker types, stateless types, or types used to implement traits.

For example:

1
2
3
4
5
6
7
8
9
10
11
struct JsonFormat;

trait Format {
    fn content_type(&self) -> &'static str;
}

impl Format for JsonFormat {
    fn content_type(&self) -> &'static str {
        "application/json"
    }
}

JsonFormat carries no data but represents a particular behavior or configuration.

2. Methods and Associated Functions

Rust defines methods and associated functions inside an impl block.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn is_square(&self) -> bool {
        self.width == self.height
    }
}

Usage:

1
2
3
4
5
6
7
let rectangle = Rectangle {
    width: 10,
    height: 5,
};

println!("{}", rectangle.area());
println!("{}", rectangle.is_square());

2.1 The self parameter

Rust methods commonly use three receiver forms.

ReceiverMeaning
&selfBorrows the instance immutably
&mut selfBorrows the instance mutably
selfTakes ownership of the instance

Immutable borrow

1
2
3
4
5
impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

The method can read fields but cannot directly modify them.

Mutable borrow

1
2
3
4
5
6
impl Rectangle {
    fn resize(&mut self, width: u32, height: u32) {
        self.width = width;
        self.height = height;
    }
}

Usage:

1
2
3
4
5
6
let mut rectangle = Rectangle {
    width: 10,
    height: 5,
};

rectangle.resize(20, 10);

Ownership

1
2
3
4
5
impl Rectangle {
    fn destroy(self) {
        println!("Rectangle consumed");
    }
}

Calling rectangle.destroy() moves the value into the method. If the type is not Copy, the original binding cannot be used afterward.

2.2 Associated functions

An associated function does not require an instance receiver such as self.

1
2
3
4
5
6
7
impl Rectangle {
    fn new(width: u32, height: u32) -> Self {
        Self { width, height }
    }
}

let rectangle = Rectangle::new(10, 5);

Self refers to the type being implemented.

Associated functions are often used as constructors, although Rust does not have special constructor syntax.

Other examples include helper functions and factory functions.

3. Struct Visibility and Encapsulation

Struct fields are private by default outside their defining module.

1
2
3
4
pub struct User {
    username: String,
    active: bool,
}

Here, the type User is public, but its fields are private.

External modules cannot directly access those fields. Instead, expose methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub struct User {
    username: String,
    active: bool,
}

impl User {
    pub fn new(username: String) -> Self {
        Self {
            username,
            active: true,
        }
    }

    pub fn username(&self) -> &str {
        &self.username
    }

    pub fn deactivate(&mut self) {
        self.active = false;
    }
}

This lets the type control how its state is created and modified.

The constructor also provides a place to enforce invariants, such as validating a username before storing it.

4. Enums

An enum defines a type that can represent one of several variants.

1
2
3
4
5
6
enum Direction {
    North,
    South,
    East,
    West,
}

Create a value by choosing a variant:

1
let direction = Direction::North;

At a given moment, a Direction value represents one of the four variants.

Enums are particularly useful when a value must belong to a defined set of alternatives.

4.1 Matching enum variants

Use match to handle each possible variant.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Direction {
    North,
    South,
    East,
    West,
}

fn describe(direction: Direction) {
    match direction {
        Direction::North => println!("Moving north"),
        Direction::South => println!("Moving south"),
        Direction::East => println!("Moving east"),
        Direction::West => println!("Moving west"),
    }
}

Rust checks that a match is exhaustive. Every possible variant must be handled, unless a wildcard or another catch-all pattern covers the remaining cases.

This makes enums useful for representing states that must be handled explicitly.

4.2 Enums with data

Each variant can contain data.

1
2
3
4
5
6
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
}

This enum represents four kinds of messages:

  • Quit contains no data.
  • Move contains named x and y fields.
  • Write contains a String.
  • ChangeColor contains three u8 values.

Create values:

1
2
3
4
5
6
7
8
9
10
let quit = Message::Quit;

let movement = Message::Move {
    x: 10,
    y: 20,
};

let write = Message::Write(String::from("hello"));

let color = Message::ChangeColor(255, 0, 0);

An enum can carry different data shapes while remaining one concrete type.

4.3 Matching data-bearing variants

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn process_message(message: Message) {
    match message {
        Message::Quit => {
            println!("Quit requested");
        }
        Message::Move { x, y } => {
            println!("Move to ({x}, {y})");
        }
        Message::Write(text) => {
            println!("{text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("RGB: {r}, {g}, {b}");
        }
    }
}

The patterns extract data from the selected variant.

For a named-field variant, use a struct-style pattern. For a tuple variant, use parentheses.

5. if let and let else

When you care about only one enum variant, if let can be more concise than match.

1
2
3
4
5
let message = Message::Write(String::from("hello"));

if let Message::Write(text) = message {
    println!("{text}");
}

To handle the other cases explicitly, you can use else:

1
2
3
4
5
if let Message::Write(text) = message {
    println!("{text}");
} else {
    println!("Not a write message");
}

You can also use let else when a pattern must match to continue:

1
2
3
4
5
6
7
fn extract_text(message: Message) -> String {
    let Message::Write(text) = message else {
        return String::new();
    };

    text
}

The else block must diverge, for example by returning, breaking, or panicking.

6. The Option Enum

Rust uses Option<T> to represent a value that may or may not exist.

Conceptually, its definition is:

1
2
3
4
enum Option<T> {
    Some(T),
    None,
}

Examples:

1
2
3
let name: Option<String> = Some(String::from("Amir"));

let missing_name: Option<String> = None;

Handle both possibilities:

1
2
3
4
match name {
    Some(value) => println!("{value}"),
    None => println!("No name"),
}

Unlike a nullable pointer in some languages, Option<T> makes the absence of a value explicit in the type.

It is commonly used for optional configuration, lookup results, and values that may not exist.

7. The Result Enum

Result<T, E> represents either success or failure.

Conceptually:

1
2
3
4
enum Result<T, E> {
    Ok(T),
    Err(E),
}

Example:

1
2
3
4
5
6
let result = "42".parse::<u32>();

match result {
    Ok(number) => println!("Number: {number}"),
    Err(error) => println!("Invalid number: {error}"),
}

The successful variant contains a T; the error variant contains an E.

This is the foundation of Rust’s explicit error handling and the ? operator.

8. Enums and State Machines

Enums are especially useful for modeling state machines.

1
2
3
4
5
6
enum ConnectionState {
    Disconnected,
    Connecting,
    Connected { peer_id: u64 },
    Failed(String),
}

A connection can be in one of four states, with extra data attached to the connected and failed states.

You can handle these states:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn report_state(state: &ConnectionState) {
    match state {
        ConnectionState::Disconnected => {
            println!("Disconnected");
        }
        ConnectionState::Connecting => {
            println!("Connecting");
        }
        ConnectionState::Connected { peer_id } => {
            println!("Connected to peer {peer_id}");
        }
        ConnectionState::Failed(reason) => {
            println!("Connection failed: {reason}");
        }
    }
}

This is useful in networking, virtualization, storage, and asynchronous systems because the state and its associated data are represented together.

It also prevents unrelated state fields from accidentally representing contradictory combinations.

9. Methods on Enums

Enums can have impl blocks just like structs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum Direction {
    North,
    South,
    East,
    West,
}

impl Direction {
    fn as_str(&self) -> &'static str {
        match self {
            Self::North => "north",
            Self::South => "south",
            Self::East => "east",
            Self::West => "west",
        }
    }
}

Usage:

1
2
3
let direction = Direction::East;

println!("{}", direction.as_str());

Self::East refers to a variant of the enum currently being implemented.

Methods can be used to expose behavior without exposing the internal representation.

10. Generics in Structs

Structs can be generic over types.

1
2
3
4
struct Pair<T> {
    first: T,
    second: T,
}

Both fields must have the same type T.

1
2
3
4
5
6
7
8
9
let numbers = Pair {
    first: 10,
    second: 20,
};

let names = Pair {
    first: String::from("Amir"),
    second: String::from("Rust"),
};

To allow different types for each field:

1
2
3
4
5
6
7
8
9
struct Pair<T, U> {
    first: T,
    second: U,
}

let pair = Pair {
    first: 10,
    second: "hello",
};

Here, T and U are independent type parameters.

11. Generics in Enums

Enums can also be generic.

1
2
3
4
enum Message<T> {
    Value(T),
    Empty,
}

Examples:

1
2
3
let number = Message::Value(42);
let text = Message::Value(String::from("hello"));
let empty: Message<i32> = Message::Empty;

The Value variant contains a value of type T.

The Empty variant contains no value, but the enum type still has a concrete type parameter. That is why the example annotates Message<i32> for Empty.

Standard-library types such as Option<T> and Result<T, E> use this pattern.

12. Lifetimes in Structs

A struct can borrow data rather than own it.

1
2
3
struct User<'a> {
    name: &'a str,
}

The lifetime 'a expresses the relationship between the reference stored in User and the data it refers to.

1
2
3
4
5
6
7
let name = String::from("Amir");

let user = User {
    name: &name,
};

println!("{}", user.name);

The referenced string must remain valid for as long as the borrow stored in user is used.

If you do not need to borrow, an owned field is often simpler:

1
2
3
struct User {
    name: String,
}

Use borrowed fields when the lifetime relationship is appropriate and avoiding ownership or allocation is valuable.

13. Structs vs Enums

FeatureStructEnum
PurposeGroups fieldsRepresents alternatives
Value shapeHas all its fieldsHas one active variant
Can hold dataYesYes, per variant
MethodsYesYes
GenericsYesYes
Pattern matchingPossibleEspecially useful
Typical examplesUser, Point, ConfigOption, Result, State

A struct is a good fit when the value has a consistent set of properties.

An enum is a good fit when the value represents one of several alternatives.

You can combine them:

1
2
3
4
5
6
7
8
9
10
struct User {
    id: u64,
    status: UserStatus,
}

enum UserStatus {
    Active,
    Suspended { reason: String },
    Deleted,
}

This models a user with a stable identity and a status that varies.

14. Tuple Structs vs Tuples

A tuple is a built-in compound type:

1
2
3
let point = (10, 20);

println!("{}", point.0);

A tuple struct introduces a distinct named type:

1
2
3
4
5
struct Point(i32, i32);

let point = Point(10, 20);

println!("{}", point.0);

Use a tuple for temporary grouping. Use a tuple struct when the values have a specific meaning and should not be confused with other tuples of the same shape.

15. Deriving Traits

Custom structs and enums do not automatically implement every useful formatting or comparison trait.

You can ask the compiler to generate certain implementations with derive.

1
2
3
4
5
#[derive(Debug, Clone, PartialEq, Eq)]
struct Point {
    x: i32,
    y: i32,
}

Now you can debug-print, clone, and compare points:

1
2
3
4
5
6
7
let point = Point { x: 10, y: 20 };

println!("{point:?}");

let copy = point.clone();

assert_eq!(point, copy);

Common derived traits include:

  • Debug: supports {:?} and {:#?}.
  • Clone: provides an explicit cloning operation.
  • Copy: allows implicit bitwise copying for eligible types.
  • PartialEq and Eq: enable equality comparisons.
  • PartialOrd and Ord: enable ordering comparisons.
  • Hash: enables use in hash-based collections when the other requirements are met.
  • Default: provides a default value when derivation is supported by the fields.

Copy is not appropriate for every type. For example, a struct containing a String cannot implement Copy because String owns heap memory.

16. Structs, Enums, and Trait Implementations

Traits define behavior that types can implement.

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
trait Describe {
    fn describe(&self) -> String;
}

struct Server {
    hostname: String,
}

impl Describe for Server {
    fn describe(&self) -> String {
        format!("Server: {}", self.hostname)
    }
}

enum ServiceState {
    Running,
    Stopped,
}

impl Describe for ServiceState {
    fn describe(&self) -> String {
        match self {
            Self::Running => String::from("Running"),
            Self::Stopped => String::from("Stopped"),
        }
    }
}

Both the struct and the enum implement the same trait, but provide different behavior.

This is one of Rust’s main forms of polymorphism.

17. Memory and Representation

A struct typically stores its fields together as one value, subject to padding and alignment.

1
2
3
4
struct Point {
    x: i32,
    y: i32,
}

An enum must be able to represent its largest variant, plus enough information to identify the active variant. Its exact layout is generally determined by the compiler unless a specific representation is requested.

1
2
3
4
5
enum Value {
    Integer(i64),
    Float(f64),
    Text(String),
}

This allows a single Value variable to hold any one of those alternatives.

An enum is not automatically a heap allocation. In this example, the enum value itself can store its active variant inline; the String variant owns a separate string allocation.

Use #[repr(C)] or another explicit representation only when you have a concrete representation requirement, such as a C ABI. Do not assume the default layout of an ordinary Rust struct or enum is stable across compiler versions.

18. Practical Systems Programming Example

A command-line shell can represent parsed commands with enums and structs.

1
2
3
4
5
6
7
8
9
10
11
12
use std::path::PathBuf;

struct Shell {
    current_dir: PathBuf,
}

enum Command {
    Pwd,
    Cd(PathBuf),
    Echo(Vec<String>),
    Exit,
}

The struct represents the shell’s state. The enum represents the possible commands.

You can dispatch commands with pattern matching:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn execute(command: Command, shell: &mut Shell) {
    match command {
        Command::Pwd => {
            println!("{}", shell.current_dir.display());
        }
        Command::Cd(path) => {
            shell.current_dir = path;
        }
        Command::Echo(args) => {
            println!("{}", args.join(" "));
        }
        Command::Exit => {
            println!("Exiting");
        }
    }
}

This is a simplified example: a real shell should validate paths and handle errors when changing directories.

The main idea is that an enum prevents a command from simultaneously being Pwd and Exit, while each variant carries only the data relevant to that command.

Key Takeaways

  • Use a struct to group related fields into one type.
  • Use an enum to represent one of several variants.
  • Structs can have named fields, tuple fields, or no fields.
  • Both structs and enums support methods, associated functions, generics, and trait implementations.
  • &self, &mut self, and self represent borrowing, mutable borrowing, and ownership respectively.
  • Rust’s private-by-default fields and module visibility support encapsulation.
  • Option<T> and Result<T, E> are enums that make absence and failure explicit.
  • Pattern matching works especially well with enums because Rust checks exhaustiveness.
  • Lifetimes are needed when structs store references.
  • derive can generate common trait implementations.
  • Rust uses composition and traits rather than classical class inheritance.
  • In systems programming, structs often represent resources and state, while enums represent commands, events, and state-machine alternatives.
This post is licensed under CC BY 4.0 by the author.