8 Rust Patterns for Building Type-Safe APIs That Catch Bugs at Compile Time

Learn 8 proven Rust API design patterns that use the type system to catch bugs at compile time. Write safer, cleaner code — starting today.

8 Rust Patterns for Building Type-Safe APIs That Catch Bugs at Compile Time

I remember the first time I wrote a Rust function that accepted a string when it should have accepted a number. The function compiled, ran, and then failed with a confusing error. That is the problem a type-safe API solves. It makes the compiler catch the mistake before your program ever runs. In this article, I will walk through eight patterns that I use all the time to design Rust APIs. I will keep everything simple, show real code, and explain why each pattern helps you sleep better at night.

When I say “type-safe API,” I mean an interface where wrong usage is hard or impossible to write. If someone tries to use your library the wrong way, the compiler should complain before the user even gets to run their program. The goal is to make invalid states impossible, not just hard to find. Rust gives us a powerful type system, and these eight patterns put that type system to work.

Pattern one: use newtypes to keep similar values separate.

Say you have a function that connects to a server. It needs a port number. You also have a user ID in your system. Both are numbers. Both might be u64. If you write the API with plain numbers, someone can pass a user ID where a port belongs. The compiler will happily accept it. The program will only fail later, and the error message will be painful.

A newtype is a wrapper around a primitive type. It creates a brand new type that behaves differently, even though the data inside is still a number. The compiler treats UserId and Port as completely different things.

struct UserId(u64);
struct Port(u16);

fn connect(port: Port) {
    println!("Connecting to port {}", port.0);
}

fn main() {
    let user = UserId(42);
    connect(user);
}

That code will not compile. The compiler will say that UserId was found where Port was expected. That one line of protection saves hours of debugging.

Newtypes also add documentation. When you read UserId, you know what that number means. You do not have to guess. I like to add a few helpful derives to my newtypes, because they are used as keys in maps or printed in logs.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);

Use newtypes whenever two primitive values are not actually the same thing. A phone number and a zip code are both strings, but they are not interchangeable. A password hash and a session token are both strings, but they should never mix. The newtype pattern makes those differences visible to the compiler.

Pattern two: model state changes with typestate.

A connection object goes through stages. First it is created. Then it connects. Then it can send data. Then it closes. If your API allows send on a closed connection, users will find out at runtime. I prefer to find out at compile time.

The typestate pattern puts the state of an object into the type itself. The object has a generic parameter that says what state it is in. Methods that do not make sense in a certain state simply do not exist.

use std::marker::PhantomData;

struct Connection<State> {
    address: String,
    _state: PhantomData<State>,
}

pub struct Initial;
pub struct Established;
pub struct Closed;

impl Connection<Initial> {
    pub fn new(address: &str) -> Connection<Initial> {
        Connection {
            address: address.to_string(),
            _state: PhantomData,
        }
    }

    pub fn connect(self) -> Connection<Established> {
        println!("Connected to {}", self.address);
        Connection {
            address: self.address,
            _state: PhantomData,
        }
    }
}

impl Connection<Established> {
    pub fn send(&self, data: &str) {
        println!("Sending: {}", data);
    }

    pub fn close(self) -> Connection<Closed> {
        Connection {
            address: self.address,
            _state: PhantomData,
        }
    }
}

Take a look at the connect method. It takes self, not &self. That means it consumes the connection in the Initial state and returns a connection in the Established state. You cannot call connect twice on the same connection. You cannot call send before connecting. You cannot call send after closing.

fn main() {
    let connection = Connection::<Initial>::new("example.com");

    // This does not compile:
    // connection.send("hello");
    // error: no method named `send` found for `Connection<Initial>`

    let connection = connection.connect();
    connection.send("hello");
    connection.close();

    // This does not compile:
    // connection.send("hello again");
}

The PhantomData field does not take up memory. It only tells the compiler what state the connection is in. I use typestate when a sequence of operations has a strict order. It turns invalid workflows into compile errors.

Pattern three: build fluent interfaces with consuming builders.

Builders are common in Rust. Many builder methods take &mut self. That lets you keep using the same builder object. But when you need to enforce order, &mut self is weak. A user can call methods in any order. A consuming builder takes self and returns a new builder that has moved one step forward.

Here is a tiny URL builder. It wants you to set the scheme first, then the host, then the path. If you try to set the path before the host, the compiler says no.

struct SchemeMissing;
struct SchemeSet;
struct HostSet;

struct UrlBuilder<State = SchemeMissing> {
    scheme: Option<String>,
    host: Option<String>,
    path: Option<String>,
    _state: PhantomData<State>,
}

impl UrlBuilder<SchemeMissing> {
    pub fn new() -> UrlBuilder<SchemeMissing> {
        UrlBuilder {
            scheme: None,
            host: None,
            path: None,
            _state: PhantomData,
        }
    }

    pub fn scheme(mut self, scheme: &str) -> UrlBuilder<SchemeSet> {
        self.scheme = Some(scheme.to_string());
        UrlBuilder {
            scheme: self.scheme,
            host: self.host,
            path: self.path,
            _state: PhantomData,
        }
    }
}

impl UrlBuilder<SchemeSet> {
    pub fn host(mut self, host: &str) -> UrlBuilder<HostSet> {
        self.host = Some(host.to_string());
        UrlBuilder {
            scheme: self.scheme,
            host: self.host,
            path: self.path,
            _state: PhantomData,
        }
    }
}

impl UrlBuilder<HostSet> {
    pub fn path(mut self, path: &str) -> UrlBuilder<HostSet> {
        self.path = Some(path.to_string());
        self
    }

    pub fn build(self) -> String {
        format!(
            "{}://{}/{}",
            self.scheme.unwrap(),
            self.host.unwrap(),
            self.path.unwrap_or_default()
        )
    }
}

The scheme method consumes the SchemeMissing builder and returns a SchemeSet builder. The host method only exists on SchemeSet. The path and build methods only exist on HostSet. So this works:

let url = UrlBuilder::new()
    .scheme("https")
    .host("example.com")
    .path("docs")
    .build();

println!("{}", url);

This does not compile:

let url = UrlBuilder::new()
    .host("example.com")
    .scheme("https");

You cannot set the host before the scheme, because host does not exist on UrlBuilder<SchemeMissing>. That is the power of consuming builders. The order of operations is baked into the type.

Pattern four: seal traits to control who can implement them.

Sometimes you want people to use your trait, but you do not want them to implement it for their own types. This is called sealing a trait. You keep a hidden trait in a private module. Your public trait requires that hidden trait as a supertrait. Since outsiders cannot see the hidden trait, they cannot implement it. They can still call functions that use your trait.

Let me show you a small example.

mod private {
    pub trait Sealed {}
}

#[allow(private_bounds)]
pub trait Greeter: private::Sealed {
    fn greet(&self) -> String;
}

pub struct Dog;
pub struct Cat;

impl private::Sealed for Dog {}
impl Greeter for Dog {
    fn greet(&self) -> String {
        "Woof".to_string()
    }
}

impl private::Sealed for Cat {}
impl Greeter for Cat {
    fn greet(&self) -> String {
        "Meow".to_string()
    }
}

An external user can do this:

fn print_greeting<T: Greeter>(value: &T) {
    println!("{}", value.greet());
}

They can call print_greeting with a Dog or a Cat. But if they try to implement Greeter for their own Horse type, they need to implement private::Sealed, and they cannot even name that module. The compiler stops them.

Why does this matter? It gives you room to change the trait later. If you want to add a new required method to Greeter, you can do it without breaking your users’ code, because no one outside your crate will have implemented the trait. You keep that freedom while still offering a useful public interface.

Pattern five: use associated types when a trait should have one clear output type.

A common mistake in generic programming is to put too many type parameters on a trait. That can make code noisy and hard to read. Associated types let you say: for this implementation, there is one specific output type, and I do not want the user to choose it.

Here is an example with a Database trait.

pub trait Database {
    type Table;
    type Error;

    fn get_table(&self, name: &str) -> Result<Self::Table, Self::Error>;
}

pub struct SqliteTable;
pub struct SqliteError;

pub struct Sqlite;

impl Database for Sqlite {
    type Table = SqliteTable;
    type Error = SqliteError;

    fn get_table(&self, name: &str) -> Result<Self::Table, Self::Error> {
        // pretend we looked up a table
        Ok(SqliteTable)
    }
}

If I used a generic trait instead, it would look like this:

pub trait Database<Table, Error> {
    fn get_table(&self, name: &str) -> Result<Table, Error>;
}

The generic version creates a problem. The type Sqlite could implement Database<SqliteTable, SqliteError> and also Database<OtherTable, OtherError>. That is usually not what you want. With associated types, each implementation has exactly one Table type and one Error type.

Associated types also make function signatures cleaner. When you write a generic function, you do not need to repeat the table type as a generic parameter.

fn print_table_names<T: Database>(db: &T) {
    let table = db.get_table("users");
    let _result: Result<T::Table, T::Error> = table;
}

The user of the function only needs to say T: Database. The compiler figures out the rest. This pattern is especially good for traits that represent a single capability, like a collection, an iterator, or a database.

Pattern six: provide multiple clearly named constructors.

I used to write constructors with one new function that had a huge list of parameters. Then I learned that a constructor is a place where a user makes a decision. If the decision is obvious, give it a clear name.

For example, an Email type should not accept just any string. It should validate the string. But there are different ways to create an email. One way is from a complete address. Another way is from a local part and a domain. Give both operations clear names.

pub struct Email {
    address: String,
}

impl Email {
    pub fn new(address: &str) -> Result<Email, String> {
        if address.contains('@') {
            Ok(Email {
                address: address.to_string(),
            })
        } else {
            Err("email must contain an @ symbol".to_string())
        }
    }

    pub fn from_parts(local: &str, domain: &str) -> Result<Email, String> {
        if local.is_empty() {
            return Err("local part is empty".to_string());
        }
        if domain.contains('@') {
            return Err("domain contains an @ symbol".to_string());
        }
        Email::new(&format!("{}@{}", local, domain))
    }
}

Now the code reads like a sentence. Email::new("[email protected]") says exactly what it does. Email::from_parts("bob", "example.com") also says exactly what it does. There is no boolean flag like Email::new(address, true) to wonder about.

I also use this pattern for configuration objects. Instead of one Config::new with many optional arguments, I define constructors like Config::from_env and Config::for_test. Each constructor carries intent. It tells the person who reads the code what kind of setup is happening.

pub struct Config {
    pub host: String,
    pub port: u16,
    pub timeout_secs: u64,
}

impl Config {
    pub fn from_env() -> Config {
        let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
        let port = std::env::var("PORT")
            .ok()
            .and_then(|value| value.parse().ok())
            .unwrap_or(8080);
        let timeout_secs = std::env::var("TIMEOUT")
            .ok()
            .and_then(|value| value.parse().ok())
            .unwrap_or(30);

        Config {
            host,
            port,
            timeout_secs,
        }
    }

    pub fn for_test() -> Config {
        Config {
            host: "127.0.0.1".to_string(),
            port: 0,
            timeout_secs: 1,
        }
    }
}

If someone wants a test config, they call Config::for_test. If someone wants a real config from the environment, they call Config::from_env. There is no way to accidentally mix up the two intentions.

Pattern seven: use #[non_exhaustive] to leave room for future changes.

When you write a public enum, you are making a promise: these are all the possible values. But what if you need to add another value later? Adding a value can break your users because their match statements may become non-exhaustive. The compiler will force them to handle the new value, and that can be annoying.

#[non_exhaustive] tells Rust that this enum may grow in the future. External code must include a wildcard arm when matching, because there might be values that even you have not thought of yet. This gives you room to add new variants without breaking your users.

#[non_exhaustive]
pub enum UserAction {
    Login,
    Logout,
    UpdateProfile { name: String },
}

When someone outside your crate matches on UserAction, they must handle the unknown case.

fn handle_action(action: UserAction) {
    match action {
        UserAction::Login => {
            println!("Login");
        }
        UserAction::Logout => {
            println!("Logout");
        }
        UserAction::UpdateProfile { name } => {
            println!("Update profile to {}", name);
        }
        _ => {
            // In the future, there might be more actions.
        }
    }
}

The same thing works for structs. If you mark a struct as #[non_exhaustive], people outside your crate cannot use a struct literal to create it. They must use a constructor you provide. That means you can add new private fields later without forcing them to change how they build the struct.

I use #[non_exhaustive]] when I expect the shape of data to evolve. It is a small mark on your code, but it gives you a lot of freedom.

Pattern eight: make illegal states impossible with enums.

This is the pattern that changes the way you think about data. Instead of using booleans and Option fields to represent the state of something, use an enum. An enum can only be one thing at a time. That means you cannot create a combination that does not make sense.

Look at this bad design:

pub struct TaskBad {
    title: String,
    is_done: bool,
    is_cancelled: bool,
}

A user can set both is_done and is_cancelled to true. What does that mean? Is the task done or cancelled? Nobody knows. This is an illegal state, and the type system allows it.

Now look at a better design:

pub enum TaskStatus {
    Pending,
    Done,
    Cancelled,
}

pub struct Task {
    title: String,
    status: TaskStatus,
}

A task has one status, and only one status. You cannot be both done and cancelled. That simple change removes a whole class of bugs.

Enums become even more powerful when they carry data in each variant. Consider a payment system.

pub enum Payment {
    Cash,
    Card {
        last_four: String,
        exp_year: u32,
    },
}

If a payment is Cash, there is no card number at all. If a payment is Card, the card number and expiration year are always present. You do not need Option<String> for the card number, because the variant itself says whether the card is there. This is what people mean when they say “make illegal states impossible.”

Putting the patterns together.

You do not need to use only one pattern. The best APIs use several together. Here is a small example that combines newtypes and enums.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TicketId(u64);

pub enum TicketStatus {
    Open,
    InProgress,
    Closed { reason: String },
}

pub struct Ticket {
    id: TicketId,
    title: String,
    status: TicketStatus,
}

impl Ticket {
    pub fn new(id: TicketId, title: String) -> Ticket {
        Ticket {
            id,
            title,
            status: TicketStatus::Open,
        }
    }

    pub fn start(&mut self) {
        if matches!(self.status, TicketStatus::Open) {
            self.status = TicketStatus::InProgress;
        }
    }

    pub fn close(&mut self, reason: String) {
        self.status = TicketStatus::Closed { reason };
    }
}

The TicketId newtype makes sure you never pass a plain u64 to something that expects a ticket ID. The TicketStatus enum makes sure a ticket cannot be both open and closed. The constructor sets the starting state to a valid value. The methods move the status forward in a controlled way.

When you combine these patterns, your API becomes a guide. The compiler tells the user what to do next. A user cannot close a ticket without a reason, because close requires a String. A user cannot call start on a closed ticket without an explicit check, because start checks the current status and only moves to InProgress if the status is Open.

I learned these patterns by writing many small Rust libraries and making many mistakes. The first versions were painful. They relied on runtime checks and panics. Later versions moved more and more responsibility into the type system. Now I trust the compiler more than my memory. If a mistake is possible, I try to design the API so the mistake never compiles.

Start with newtypes. They are easy to understand and give you immediate safety. Then try typestate for ordered workflows. Use consuming builders when you want to enforce a sequence. Seal traits when you want to control extensibility. Use associated types when your trait has one clear output. Give constructors clear names so intent is obvious. Mark public enums with #[non_exhaustive] if you want room to grow. And always prefer a meaningful enum over a pile of booleans.

Each pattern is a tool in your toolbox. The more tools you have, the easier it is to build Rust APIs that are safe, simple, and hard to misuse. The next time you design a public function, ask yourself: what wrong code could a user write? Then use these patterns to turn that wrong code into a compile error. That is the power of a type-safe API.


// Keep Reading

Similar Articles