API design in Rust

A public function is a promise, and the type signature is the part of the promise the compiler enforces. A good Rust interface is mostly a matter of pushing rules into types: a newtype instead of a bare string, a Result instead of a panic, one type per state instead of a flag, and an attribute that turns a discarded value into a warning.

This lesson designs five small interfaces that way, shows the diagnostics they produce, and ends with the call that no longer compiles once the string became an email.

A newtype with a checked constructor

Two strings in a row are two arguments a caller can swap. A newtype gives each one a type of its own at no cost at run time, and a parser gives the type a guarantee: every Email contains an at sign that is neither first nor last.

The program parses one good address and one that is not an address. The error is a String and a real library would use a type of its own, which the third section builds.

struct Email(String); impl Email { fn parse(text: &str) -> Result<Email, String> { match text.find('@') { Some(index) if index > 0 && index < text.len() - 1 => Ok(Email(text.to_string())), _ => Err(format!("not an email: {text}")), } } fn as_str(&self) -> &str { &self.0 } } fn main() { let good = Email::parse("user@example.com"); let bad = Email::parse("example.com"); println!("{}", good.map(|e| e.as_str().to_string()).unwrap_or_default()); println!("{}", bad.err().unwrap_or_default()); }

rustc --edition 2024 newtype.rs
./newtype

user@example.com
not an email: example.com

The tuple field is private, so the only way to an Email is the parser that checks it. The type is one pointer and one length, exactly like the String inside it, which is why a newtype costs nothing at run time. The lesson on hiding the implementation covers the privacy, and strings covers the search that the parser uses.

The result that must be used

A function that computes a value and changes nothing is easy to call and forget. The attribute #[must_use] asks the compiler for a warning at every call site that drops the result, and the attribute on a type does the same for every function that returns it. A Result already carries the attribute.

The program calls a normalizing function and throws the answer away, then prints a word. The warning does not stop the build, and the output is still the word.

#[must_use] fn normalize(text: &str) -> String { text.trim().to_lowercase() } fn main() { let input = " Rust "; normalize(input); println!("done"); }

rustc --edition 2024 mustuse.rs
./mustuse

warning: unused return value of `normalize` that must be used
--> mustuse.rs:8:5
|
8 | normalize(input);
| ^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
help: use `let _ = ...` to ignore the resulting value
|
8 | let _ = normalize(input);
| +++++++

warning: 1 warning emitted

done

The attribute is not a run-time check and it cannot change the value; it exists so that a reader of the build log sees the call that did nothing. A constructor with side effects gets the same attribute for the opposite reason: a caller forgot to call it, and the warning points at the expression that produced the object. The help line is the escape hatch and the reason the warning is fair: a caller that really wants to drop the value writes let _ = and says so. A project that treats warnings as errors turns this into a build failure with -D warnings, which the lesson on warnings covers.

Result in the signature

A library function that can fail returns Result and lets the caller decide between a message, a retry and a panic. The error type carries what the caller needs to report: here a position and a message, which is the smallest useful pair for a parser.

Three inputs go through the same function. The good one prints the port, the second fails at the conversion and the third fails the range check.

#[derive(Debug)] struct ParseError { position: usize, message: &'static str, } fn parse_port(text: &str) -> Result<u16, ParseError> { let port: u16 = text .parse() .map_err(|_| ParseError { position: 0, message: "not a number" })?; if port < 1024 { return Err(ParseError { position: 3, message: "port is reserved" }); } Ok(port) } fn main() { for text in ["8080", "abc", "80"] { match parse_port(text) { Ok(port) => println!("port {port}"), Err(error) => println!("error at {}: {}", error.position, error.message), } } }

rustc --edition 2024 result_sig.rs
./result_sig

port 8080
error at 0: not a number
error at 3: port is reserved

The question mark moves the conversion error up and the range check returns its own, so both arrive at the caller as one type. The caller matches once and prints both, which is what a command-line program does with its exit status. The lesson on error types turns the struct into an enum and adds Display.

Types as states

A status flag in a struct permits every method in every state, and the check moves to run time. Two types, one per state, permit exactly the transitions that exist: publish is a method of the draft and takes it by value, so a draft is consumed and a published document remains.

The program builds a draft and publishes it. The published value has a title and no way back, which is the rule of the state machine written in the type system.

struct Draft { title: String, } struct Published { title: String, } impl Draft { fn new(title: &str) -> Draft { Draft { title: title.to_string() } } fn publish(self) -> Published { Published { title: self.title } } } impl Published { fn title(&self) -> &str { &self.title } } fn main() { let draft = Draft::new("notes"); let published = draft.publish(); println!("{}", published.title()); }

rustc --edition 2024 states.rs
./states

notes

The two types hold the same data and the transition moves it, so the program pays nothing for the check. A state machine of five states is five types and a method per allowed edge, which is a lot of code for a small machine and the right amount for a protocol with rules that a user must not break. The compiler enforces the order: a Draft has no title method, and a Published has no publish, so the impossible calls do not exist to be written. The cost is a copy of the data at the transition when the two types do not share a box, which is why a large document holds its body in a Box or an Arc. The lesson on markers covers the traits that make the two states behave differently without any flag at all.

The string where an email belongs

The newtype pays off at every call site: a function that takes &Email cannot be called with a string, and the error names both types.

struct Email(String); fn send(to: &Email) -> usize { to.0.len() } fn main() { println!("{}", send("user@example.com")); }

rustc --edition 2024 misuse_err.rs

error[E0308]: mismatched types
--> misuse_err.rs:8:25
|
8 | println!("{}", send("user@example.com"));
| ---- ^^^^^^^^^^^^^^^^^^ expected `&Email`, found `&str`
| |
| arguments to this function are incorrect
|
= note: expected reference `&Email`
found reference `&'static str`
note: function defined here
--> misuse_err.rs:3:4
|
3 | fn send(to: &Email) -> usize {
| ^^^^ ----------

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

In a program with two string parameters the same mistake would compile and send the wrong value to the wrong place, which is the bug the newtype prevents for free. The fix is the parser from the first section: send(&Email::parse("user@example.com")?) in a function that returns a Result. The next lesson leaves the standard library behind and designs the same interfaces for a machine with no operating system.

Exercises

  1. Add a Port newtype with a parser and use it in place of the u16 of the third section.
  2. Mark the error type with #[must_use] and find the call that stops warning.
  3. Add an Archived state and decide which methods it keeps.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does a newtype prevent that a type alias does not?

    Answer

    Swapping two arguments that have the same representation

    A newtype is a distinct type, so the compiler rejects an argument of the inner type.

  2. What does #[must_use] do?

    Answer

    Warns when the returned value is not used

    The lint unused_must_use reports a discarded value and suggests let _ =.

  3. Why does a library return Result instead of panicking?

    Answer

    The caller decides how to report the failure

    The failure becomes part of the signature and the caller chooses the message and the exit code.

  4. How does the two-type state machine prevent an invalid transition?

    Answer

    The method for the transition exists on one type only, and it consumes it

    A draft has publish and a published document does not, so the call cannot be written.

  5. Why is an error type with a position better than a String?

    Answer

    The caller can match on the kind and use the position without parsing the text

    A typed error keeps the facts separate from the wording the user sees.

  6. What does this program print? struct Email(String); impl Email { fn parse(text: &str) -> Result<Email, String> { match text.find('@') { Some(index) if index > 0 && index < text.len() - 1 => Ok(Email(text.to_string())), _ => Err(format!("bad: {text}")), } } } fn main() { let results = [Email::parse("a@b"), Email::parse("@b"), Email::parse("a@")]; for result in results { match result { Ok(email) => println!("ok {}", email.0), Err(error) => println!("{error}"), } } }

    Answer

    ok a@b / bad: @b / bad: a@

    Only the address with text on both sides of the at sign is accepted.

  7. What does this program print? #[derive(Debug)] struct ParseError { position: usize, message: &'static str, } fn parse(text: &str) -> Result<u8, ParseError> { let number: u8 = text .parse() .map_err(|_| ParseError { position: 0, message: "not a number" })?; if number > 100 { return Err(ParseError { position: 1, message: "too large" }); } Ok(number) } fn main() { let results = [parse("42"), parse("x"), parse("200")]; for result in results { match result { Ok(value) => println!("value {value}"), Err(error) => println!("{} {}", error.position, error.message), } } }

    Answer

    value 42 / 0 not a number / 1 too large

    The conversion fails at position zero and the range check fails at position one.

  8. What does this program print? struct Draft { title: String, } struct Published { title: String, } impl Draft { fn publish(self) -> Published { Published { title: self.title } } } impl Published { fn len(&self) -> usize { self.title.len() } } fn main() { let draft = Draft { title: "abc".to_string() }; let published = draft.publish(); println!("{}", published.len()); }

    Answer

    3

    The title moves into the published value and its length is three.

  9. What is the first line of the compiler output for this program? struct Email(String); fn send(to: &Email) -> usize { to.0.len() } fn main() { println!("{}", send("user@example.com")); }

    Answer

    error[E0308]: mismatched types

    The function takes &Email and the argument is a &str, which E0308 reports.

  10. What does this program print? struct Port(u16); impl Port { fn new(value: u16) -> Result<Port, &'static str> { if value < 1024 { Err("reserved") } else { Ok(Port(value)) } } fn value(&self) -> u16 { self.0 } } fn main() { let ok = Port::new(8080).map(|port| port.value()).unwrap_or(0); let bad = Port::new(80).is_err(); println!("{} {}", ok, bad); }

    Answer

    8080 true

    The first port is accepted and the second is refused as reserved.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev