Chapter 04
Structs, Enums & Pattern Matching
Custom types for modeling data, methods for giving them behavior, and pattern matching for handling every possible case.
Defining and Instantiating Structs
A struct groups related data together under a single name. If you're coming from JavaScript or Python, think of it as a typed object with a fixed shape. If you're coming from C, it's exactly what you expect - but with ownership semantics.
For our webserver, we need to represent HTTP requests. A request has a method, a path, and headers. Let's model that:
struct Request {
method: String,
path: String,
headers: Vec<(String, String)>,
}Each field has a name and a type. The struct owns its data - the String fields are owned strings, not borrowed references. When a Request is dropped, all its fields are dropped too.
You create an instance by providing values for every field:
let request = Request {
method: String::from("GET"),
path: String::from("/index.html"),
headers: vec![
(String::from("Host"), String::from("localhost:8080")),
(String::from("Accept"), String::from("text/html")),
],
};
// Access fields with dot notation
println!("{} {}", request.method, request.path);vec![] macro creates a Vec (a growable array - Rust's equivalent of an ArrayList or JavaScript array). We'll cover it properly in Chapter 7. For now, just read it as "a list of things."If the variable name matches the field name, you can use the shorthand:
let method = String::from("POST");
let path = String::from("/api/data");
let headers = vec![];
// Shorthand: field name = variable name
let request = Request { method, path, headers };Tuple Structs
When you want a named type but don't need named fields, use a tuple struct:
struct Port(u16);
struct StatusCode(u16);
let port = Port(8080);
let status = StatusCode(200);
// These are different types - you can't mix them up,
// even though both wrap a u16
// let oops: Port = status; // ERROR: type mismatchThis is called the newtype pattern. It gives you type safety with zero runtime cost. The compiler prevents you from accidentally passing a status code where a port is expected.
Methods and Associated Functions
Structs are just data. To give them behavior, you add methods inside an impl block:
struct Response {
status: u16,
headers: Vec<(String, String)>,
body: String,
}
impl Response {
/// Methods take &self, &mut self, or self as the first parameter.
/// &self borrows the struct immutably - for reading.
fn status_line(&self) -> String {
let reason = match self.status {
200 => "OK",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unknown",
};
format!("HTTP/1.1 {} {}", self.status, reason)
}
/// &mut self borrows mutably - for modifying.
fn add_header(&mut self, name: &str, value: &str) {
self.headers.push((name.to_string(), value.to_string()));
}
/// Serialize the full response to a byte string.
fn to_bytes(&self) -> Vec<u8> {
let mut output = self.status_line();
for (name, value) in &self.headers {
output.push_str(&format!("\r\n{}: {}", name, value));
}
output.push_str(&format!("\r\nContent-Length: {}", self.body.len()));
output.push_str("\r\n\r\n");
output.push_str(&self.body);
output.into_bytes()
}
}The first parameter of a method is always a form of self:
&self- borrow immutably (read the struct)&mut self- borrow mutably (modify the struct)self- take ownership (consume the struct)
You call methods with dot syntax: response.status_line(). Rust automatically adds the & or &mut - you don't need to write (&response).status_line().
Associated Functions (Constructors)
Functions in an impl block that don't take self are called associated functions. They're like static methods. The convention is to use new for constructors:
impl Response {
/// Create a new Response. No &self - this is a constructor.
fn new(status: u16, body: &str) -> Self {
Response {
status,
headers: vec![],
body: body.to_string(),
}
}
/// Convenience constructor for common responses.
fn ok(body: &str) -> Self {
Self::new(200, body)
}
fn not_found() -> Self {
Self::new(404, "<h1>404 Not Found</h1>")
}
}
// Called with :: syntax, not dot syntax
let response = Response::ok("<h1>Hello!</h1>");
let missing = Response::not_found();Self (capital S) is an alias for the type you're implementing. Inside impl Response, Self means Response. It keeps your code resilient if you rename the struct later.Enums and Variants with Data
Enums in Rust are far more powerful than enums in most languages. Each variant can carry different data - they're closer to "tagged unions" or "algebraic data types" if you've seen those terms before.
An HTTP method is a natural fit for an enum:
enum Method {
Get,
Post,
Put,
Delete,
Head,
Options,
}That's a simple enum - each variant is just a label. But Rust enums can carry data in each variant:
enum Route {
Static(String), // serves a file: "/style.css"
Api { resource: String }, // named fields: "/api/users"
NotFound, // no data
}
let route = Route::Api { resource: String::from("users") };Each variant can have different shapes - tuple-style, struct-style, or no data at all. This lets you model "one of several possible things" cleanly. The compiler ensures you handle every variant.
Enums can have methods too, just like structs:
impl Method {
fn from_str(s: &str) -> Method {
match s {
"GET" => Method::Get,
"POST" => Method::Post,
"PUT" => Method::Put,
"DELETE" => Method::Delete,
"HEAD" => Method::Head,
"OPTIONS" => Method::Options,
_ => Method::Get, // default fallback
}
}
fn is_safe(&self) -> bool {
match self {
Method::Get | Method::Head | Method::Options => true,
_ => false,
}
}
}Option and Result - No Null, No Exceptions
Rust has no null and no exceptions. Instead, it uses two enums from the standard library to represent "maybe nothing" and "maybe an error." These are so fundamental that they're imported automatically - you never need to write use for them.
Option<T> - a value that might not exist
// Option is defined as:
// enum Option<T> {
// Some(T),
// None,
// }
/// Look up a header value by name.
fn get_header<'a>(
headers: &'a [(String, String)],
name: &str,
) -> Option<&'a str> {
for (key, value) in headers {
if key.eq_ignore_ascii_case(name) {
return Some(value);
}
}
None
}
let headers = vec![
(String::from("Host"), String::from("localhost")),
(String::from("Accept"), String::from("text/html")),
];
let host = get_header(&headers, "Host");
let auth = get_header(&headers, "Authorization");
println!("Host: {:?}", host); // Some("localhost")
println!("Auth: {:?}", auth); // NoneThe type system forces you to handle the None case. You can't just use the value - you have to unwrap it first. This eliminates null pointer exceptions entirely.
// You can't just use an Option<&str> as a &str:
// println!("Host is {}", host); // ERROR
// Handle it explicitly:
match host {
Some(value) => println!("Host is {}", value),
None => println!("No Host header"),
}
// Or use unwrap_or for a default:
let host_str = host.unwrap_or("unknown");
// Or use if let when you only care about the Some case:
if let Some(value) = host {
println!("Got host: {}", value);
}Result<T, E> - success or failure
Where Option is "value or nothing," Result is "value or error." It's how Rust handles operations that can fail - parsing, file I/O, network connections:
// Result is defined as:
// enum Result<T, E> {
// Ok(T),
// Err(E),
// }
/// Parse a port from a string, returning an error message on failure.
fn parse_port(input: &str) -> Result<u16, String> {
match input.parse::<u16>() {
Ok(port) if port > 0 => Ok(port),
Ok(_) => Err(String::from("port must be greater than 0")),
Err(e) => Err(format!("invalid port number: {}", e)),
}
}
let good = parse_port("8080"); // Ok(8080)
let bad = parse_port("abc"); // Err("invalid port number: ...")
let zero = parse_port("0"); // Err("port must be greater than 0")Handling a Result works exactly like Option:
match parse_port("8080") {
Ok(port) => println!("Listening on port {}", port),
Err(e) => println!("Error: {}", e),
}
// unwrap_or for a default:
let port = parse_port("abc").unwrap_or(8080);
// unwrap() panics on Err - use only when you're certain it won't fail:
let port = parse_port("8080").unwrap(); // fine here, would crash on "abc".unwrap() crashes your program if the value is None or Err. It's fine in examples and prototypes, but in a real webserver you should handle errors properly. We'll cover the ? operator and custom error types in Chapter 8.Pattern Matching with match and if let
You've already seen match in the examples above. Let's look at it more closely - it's one of Rust's most powerful features.
A match expression compares a value against a series of patterns. The compiler guarantees that every possible case is handled - if you forget one, it won't compile.
fn handle_request(method: &Method, path: &str) -> Response {
match method {
Method::Get => {
match path {
"/" | "/index.html" => Response::ok("<h1>Welcome!</h1>"),
"/about" => Response::ok("<h1>About</h1>"),
_ => Response::not_found(),
}
}
Method::Post => {
match path {
"/api/data" => Response::ok("{\"status\": \"created\"}"),
_ => Response::not_found(),
}
}
// The compiler requires this - all variants must be covered
_ => Response::new(405, "Method Not Allowed"),
}
}Key features of match:
- Exhaustive: you must cover every possible value. The
_pattern is the catch-all. - Pattern binding: you can extract data from variants.
- Guards: add conditions with
if. - Multiple patterns: use
|to match several values.
// Extracting data from enum variants
fn describe_route(route: &Route) -> String {
match route {
Route::Static(path) => format!("Serving static file: {}", path),
Route::Api { resource } => format!("API endpoint: /api/{}", resource),
Route::NotFound => String::from("404"),
}
}
// Guards and binding
fn categorize_status(code: u16) -> &'static str {
match code {
200..=299 => "success",
300..=399 => "redirect",
400..=499 => "client error",
500..=599 => "server error",
c if c < 200 => "informational",
_ => "unknown",
}
}if let - When You Only Care About One Case
Sometimes a full match is overkill. If you only care about one variant, if let is more concise:
// Instead of this:
match get_header(&headers, "Content-Length") {
Some(value) => println!("Body size: {}", value),
None => {} // do nothing
}
// Write this:
if let Some(value) = get_header(&headers, "Content-Length") {
println!("Body size: {}", value);
}
// With an else branch:
if let Some(auth) = get_header(&headers, "Authorization") {
println!("Authenticated: {}", auth);
} else {
println!("No credentials provided");
}match when you need to handle multiple cases or when the compiler requires exhaustive coverage. Use if let when you only care about one variant and want to ignore the rest. There's also while let for loops, which we'll use later when reading from TCP streams.Putting It Together
Let's combine structs, enums, methods, and pattern matching into a mini request/response system. This is the shape our actual webserver code will take - we're building the types now so they're ready when we start networking in the next chapter:
#[derive(Debug)]
enum Method {
Get,
Post,
Unknown(String),
}
impl Method {
fn from_str(s: &str) -> Method {
match s {
"GET" => Method::Get,
"POST" => Method::Post,
other => Method::Unknown(other.to_string()),
}
}
}
#[derive(Debug)]
struct Request {
method: Method,
path: String,
}
struct Response {
status: u16,
body: String,
}
impl Response {
fn new(status: u16, body: &str) -> Self {
Response { status, body: body.to_string() }
}
fn ok(body: &str) -> Self { Self::new(200, body) }
fn not_found() -> Self { Self::new(404, "Not Found") }
fn to_string(&self) -> String {
let reason = match self.status {
200 => "OK",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Unknown",
};
format!(
"HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
self.status, reason, self.body.len(), self.body
)
}
}
fn handle(request: &Request) -> Response {
match &request.method {
Method::Get => match request.path.as_str() {
"/" => Response::ok("<h1>Welcome!</h1>"),
"/about" => Response::ok("<h1>About</h1>"),
_ => Response::not_found(),
},
Method::Post => match request.path.as_str() {
"/submit" => Response::ok("Received"),
_ => Response::not_found(),
},
Method::Unknown(m) => {
Response::new(405, &format!("Unsupported method: {}", m))
}
}
}
fn main() {
let requests = vec![
Request { method: Method::from_str("GET"), path: String::from("/") },
Request { method: Method::from_str("GET"), path: String::from("/missing") },
Request { method: Method::from_str("POST"), path: String::from("/submit") },
Request { method: Method::from_str("PATCH"), path: String::from("/") },
];
for req in &requests {
let resp = handle(req);
println!("{:?} {} → {} ", req.method, req.path, resp.status);
println!("{}", resp.to_string());
println!("---");
}
}Run it with cargo run. Notice how Method::Unknown(String) captures unrecognized methods with their data intact - the enum variant carries the string along. The handle function uses nested match to route by method first, then by path. Every case is covered.
#[derive(Debug)] attribute automatically implements the Debug trait, which lets you print a struct or enum with {:?}. You'll put this on almost every type you define - it's invaluable during development.Exercise
- Add
Put,Delete, andHeadvariants to theMethodenum. Updatefrom_strand thehandlefunction. Notice how the compiler tells you exactly where you missed a case. - Add a
headersfield toRequestas aVec<(String, String)>. Write aget_header(&self, name: &str) -> Option<&str>method that searches case-insensitively. - Create a
ContentTypeenum with variantsHtml,Json,PlainText. Add a methodas_str(&self) -> &strthat returns the MIME string (e.g."text/html"). Use it inResponse. - Modify
parse_portfrom earlier to returnResult<u16, String>. Usematchinmainto print either the port or the error message.
What's Next
You now have the tools to model data in Rust. Structs give you named fields, enums give you variants, impl blocks give them behavior, and match ensures you handle every case the compiler knows about.
In the next chapter, we put it all together and write actual networking code. We'll open a TCP socket, listen for connections, and read real HTTP requests off the wire - using the Request and Response types we just built.