Rust

Defensive API Design

Practical patterns for making Rust crate APIs harder to misuse — must_use, doc aliases, exhaustive matching, and more

Defensive API Design

Good library APIs don't just work when used correctly — they resist misuse. Rust's type system gets you halfway there, but a few deliberate patterns close the remaining gaps. This guide covers practical techniques we've applied across real RF engineering crates.

#[must_use] — Don't Let Results Vanish

The most common API mistake: calling a function and ignoring its return value.

// Bug: the converted value is silently discarded
db_to_linear(gain_db);

This compiles, runs, and does nothing useful. The #[must_use] attribute turns this into a compiler warning:

#[must_use = "this returns the converted value and does not modify the input"]
pub fn db_to_linear(db: f64) -> f64 {
    10.0_f64.powf(db / 10.0)
}
warning: unused return value of `db_to_linear` that must be used
  --> src/main.rs:4:5
   |
4  |     db_to_linear(gain_db);
   |     ^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this returns the converted value and does not modify the input

Where to Apply It

Functions that return computed values (pure functions):

#[must_use]
pub fn cascade_noise_figure(nf1_db: f64, nf2_db: f64, gain1_db: f64) -> f64 {
    // ...
}

Types that represent results — apply it to the struct itself:

#[must_use = "LinkBudget contains the analysis results"]
pub struct LinkBudget {
    pub margin_db: f64,
    pub cn0_db_hz: f64,
    // ...
}

When NOT to use it: Functions called primarily for side effects (writing files, printing, modifying state). If the return value is just a status indicator that's sometimes useful, #[must_use] will generate noise.

The Sweep Strategy

When retrofitting #[must_use] to an existing crate, don't sprinkle it randomly. Sweep systematically:

  1. Start with all public functions that return non-() values
  2. Remove it from functions where the return value is legitimately optional
  3. Add custom messages for non-obvious cases
  4. Run cargo clippy — it also suggests #[must_use] candidates via clippy::must_use_candidate
cargo clippy -- -W clippy::must_use_candidate 2>&1 | head -40

#[doc(alias)] — Help Users Find Your API

Engineers search for APIs using domain terminology that doesn't always match Rust naming conventions. doc(alias) bridges the gap:

/// Convert noise figure (dB) to noise temperature (Kelvin).
#[doc(alias = "NF to T")]
#[doc(alias = "noise temperature")]
#[doc(alias = "T_noise")]
pub fn noise_figure_to_temperature(nf_db: f64) -> f64 {
    let nf_linear = 10.0_f64.powf(nf_db / 10.0);
    290.0 * (nf_linear - 1.0)
}

Now searching for "T_noise" or "noise temperature" in cargo doc or docs.rs finds this function. Especially valuable when:

  • Your function name uses Rust conventions (noise_figure_to_temperature) but the domain uses symbols (T_N, NF)
  • Multiple terms exist for the same concept (noise figure / noise factor / noise temperature)
  • Users might search for the formula name, not the function name

Validated Construction — Parse, Don't Validate

Instead of accepting raw f64 everywhere and checking at runtime, validate at the boundary:

/// A frequency that is guaranteed positive and finite.
#[derive(Debug, Clone, Copy)]
pub struct Frequency {
    hz: f64,
}

#[derive(Debug, thiserror::Error)]
#[error("frequency must be positive and finite, got {0}")]
pub struct InvalidFrequency(f64);

impl Frequency {
    pub fn from_hz(hz: f64) -> Result<Self, InvalidFrequency> {
        if hz > 0.0 && hz.is_finite() {
            Ok(Self { hz })
        } else {
            Err(InvalidFrequency(hz))
        }
    }

    pub fn from_ghz(ghz: f64) -> Result<Self, InvalidFrequency> {
        Self::from_hz(ghz * 1e9)
    }

    pub fn hz(&self) -> f64 {
        self.hz
    }
}

Now every function that takes Frequency knows it's valid — no defensive checks scattered through the codebase. The validation happens once, at creation.

When Raw f64 Is Fine

Not everything needs wrapping. Use validated types when:

  • Invalid values cause subtle wrong results (negative frequency → wrong wavelength)
  • The value crosses API boundaries frequently
  • Domain experts would immediately flag the invalid value

Skip it when the function is simple arithmetic that works on any finite float.

Exhaustive Matching with #[non_exhaustive]

When your crate defines enums that might grow, #[non_exhaustive] forces downstream users to handle the _ wildcard — so adding a variant is never a breaking change:

#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub enum Modulation {
    Bpsk,
    Qpsk,
    Psk8,
    Apsk16,
    Apsk32,
}

Downstream code must include a wildcard arm:

match modulation {
    Modulation::Bpsk => { /* ... */ }
    Modulation::Qpsk => { /* ... */ }
    _ => { /* handle future variants */ }
}

Trade-off: This reduces exhaustiveness checking for downstream users. Only use it for enums you genuinely expect to extend.

#![warn(missing_docs)] — Enforce Documentation

At the crate root:

#![warn(missing_docs)]

Every public item without a doc comment triggers a compiler warning. This is a quality gate — it's easy to add a function and forget to document it, but the compiler won't let you forget.

Pair with #![deny(missing_docs)] once coverage is complete to prevent regressions.

Documentation Patterns for Engineering APIs

/// Compute free-space path loss.
///
/// # Arguments
///
/// * `distance_m` — Distance in meters (must be positive)
/// * `frequency_hz` — Carrier frequency in hertz (must be positive)
///
/// # Returns
///
/// Path loss in dB (always positive).
///
/// # Formula
///
/// ```text
/// FSPL(dB) = 20·log₁₀(4πdf/c)
/// ```
///
/// # Examples
///
/// ```
/// use rfconversions::free_space_path_loss;
///
/// let fspl = free_space_path_loss(1000.0, 1e9);
/// assert!((fspl - 92.45).abs() < 0.1);
/// ```
#[must_use]
pub fn free_space_path_loss(distance_m: f64, frequency_hz: f64) -> f64 {
    // ...
}

Include the formula in text code blocks — engineers want to verify the math, not just read prose.

Putting It All Together

A checklist for publishing a defensive crate:

  1. #![warn(missing_docs)] at crate root
  2. #[must_use] on all pure public functions and result types
  3. #[doc(alias)] for domain-specific search terms
  4. Validated types at API boundaries where invalid inputs cause subtle bugs
  5. #[non_exhaustive] on enums you expect to extend
  6. cargo clippy with must_use_candidate and missing_docs_in_private_items
  7. cargo semver-checks before every publish (catches accidental breaking changes)
  8. cargo mutants to verify tests actually exercise the logic

These patterns compound. A crate with all eight is remarkably hard to misuse — and the compiler does the enforcement, not code review.

Further Reading