Why a typed query builder is useful

Query strings often carry business logic: pagination, search terms, filters, feature flags, and sorting. When these are represented as raw String values, the compiler cannot help you catch invalid states.

A typed builder gives you:

  • Correct encoding for reserved characters and spaces
  • Validation for empty values, invalid ranges, or conflicting options
  • Readable call sites that document intent
  • Testable output with deterministic parameter ordering
  • Safer refactoring when API requirements change

This pattern is especially useful for internal SDKs, API clients, CLI tools that generate URLs, and services that need to compose request URLs from multiple sources.


What we are building

We will implement a QueryBuilder that can:

  • add string, integer, and boolean parameters
  • support repeated keys for multi-value filters
  • encode values safely
  • preserve insertion order
  • render a final query string or full URL

We will keep the API small and focused, but the design will scale well if you later add typed filters or domain-specific validation.


Core design

A query string is conceptually just a list of key-value pairs. In Rust, the simplest representation is a vector of tuples:

Vec<(String, String)>

This is easy to reason about and preserves insertion order. For encoding, we can use the urlencoding crate, which handles percent-encoding of query components.

Dependency

Add this to Cargo.toml:

[dependencies]
urlencoding = "2"

Implementing the builder

Here is a complete implementation:

use std::fmt;

#[derive(Debug, Default, Clone)]
pub struct QueryBuilder {
    params: Vec<(String, String)>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueryError {
    EmptyKey,
    EmptyValue,
    InvalidRange { min: i64, max: i64 },
}

impl fmt::Display for QueryError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            QueryError::EmptyKey => write!(f, "query parameter key cannot be empty"),
            QueryError::EmptyValue => write!(f, "query parameter value cannot be empty"),
            QueryError::InvalidRange { min, max } => {
                write!(f, "invalid range: min ({min}) must be <= max ({max})")
            }
        }
    }
}

impl std::error::Error for QueryError {}

impl QueryBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn add<K, V>(&mut self, key: K, value: V) -> Result<&mut Self, QueryError>
    where
        K: Into<String>,
        V: Into<String>,
    {
        let key = key.into();
        let value = value.into();

        if key.trim().is_empty() {
            return Err(QueryError::EmptyKey);
        }
        if value.trim().is_empty() {
            return Err(QueryError::EmptyValue);
        }

        self.params.push((key, value));
        Ok(self)
    }

    pub fn add_opt<K, V>(&mut self, key: K, value: Option<V>) -> Result<&mut Self, QueryError>
    where
        K: Into<String>,
        V: Into<String>,
    {
        if let Some(v) = value {
            self.add(key, v)?;
        }
        Ok(self)
    }

    pub fn add_bool<K>(&mut self, key: K, value: bool) -> Result<&mut Self, QueryError>
    where
        K: Into<String>,
    {
        self.add(key, value.to_string())
    }

    pub fn add_int<K>(&mut self, key: K, value: i64) -> Result<&mut Self, QueryError>
    where
        K: Into<String>,
    {
        self.add(key, value.to_string())
    }

    pub fn add_range<K>(&mut self, key: K, min: i64, max: i64) -> Result<&mut Self, QueryError>
    where
        K: Into<String>,
    {
        if min > max {
            return Err(QueryError::InvalidRange { min, max });
        }

        self.add(format!("{key}_min"), min.to_string())?;
        self.add(format!("{key}_max"), max.to_string())?;
        Ok(self)
    }

    pub fn add_multi<K, V, I>(&mut self, key: K, values: I) -> Result<&mut Self, QueryError>
    where
        K: Into<String> + Clone,
        V: Into<String>,
        I: IntoIterator<Item = V>,
    {
        let key = key.into();
        for value in values {
            self.add(key.clone(), value.into())?;
        }
        Ok(self)
    }

    pub fn to_query_string(&self) -> String {
        self.params
            .iter()
            .map(|(k, v)| {
                format!(
                    "{}={}",
                    urlencoding::encode(k),
                    urlencoding::encode(v)
                )
            })
            .collect::<Vec<_>>()
            .join("&")
    }

    pub fn is_empty(&self) -> bool {
        self.params.is_empty()
    }
}

Example usage

fn main() -> Result<(), QueryError> {
    let mut builder = QueryBuilder::new();

    builder
        .add("search", "rust ownership")?
        .add_int("page", 2)?
        .add_int("per_page", 25)?
        .add_bool("include_archived", false)?
        .add_multi("tag", ["systems", "async", "web"])?
        .add_range("price", 10, 50)?;

    let query = builder.to_query_string();
    println!("{query}");

    Ok(())
}

A possible output is:

search=rust%20ownership&page=2&per_page=25&include_archived=false&tag=systems&tag=async&tag=web&price_min=10&price_max=50

Why this API works well

The builder uses a mutable fluent style: each method returns Result<&mut Self, QueryError>. This gives you two benefits:

  1. Chaining remains ergonomic.
  2. Validation happens at the point of insertion, not at the end.

That means invalid state never accumulates silently. If a caller passes an empty key or a reversed range, the error is immediate and local.

A note on ownership

The methods accept Into<String> so callers can pass &str, String, or formatted values. Internally, the builder owns its data, which avoids lifetime complexity and makes the final query string independent of the input sources.


Supporting repeated keys and filters

Many APIs use repeated parameters for filters:

  • tag=rust&tag=web
  • status=open&status=triaged

This is why Vec<(String, String)> is a better fit than HashMap<String, String>. A map would overwrite duplicate keys and lose ordering.

Comparison of storage strategies

Storage typePreserves orderAllows duplicatesBest for
HashMap<String, String>NoNoUnique settings, last-write-wins semantics
BTreeMap<String, String>Sorted by keyNoCanonical ordering, unique keys
Vec<(String, String)>YesYesQuery strings, repeated filters, stable output

For query construction, Vec<(String, String)> is usually the most practical choice.


Building a full URL

Often you need more than a query string; you need a complete URL. You can extend the builder with a helper that appends the query to a base URL.

impl QueryBuilder {
    pub fn append_to_url(&self, base_url: &str) -> String {
        if self.is_empty() {
            return base_url.to_string();
        }

        let separator = if base_url.contains('?') { "&" } else { "?" };
        format!("{base_url}{separator}{}", self.to_query_string())
    }
}

Example

fn main() -> Result<(), QueryError> {
    let mut builder = QueryBuilder::new();
    builder.add("q", "rust traits")?.add_int("page", 1)?;

    let url = builder.append_to_url("https://api.example.com/search");
    assert_eq!(
        url,
        "https://api.example.com/search?q=rust%20traits&page=1"
    );

    Ok(())
}

This helper is intentionally simple. In production code, you may want to parse the base URL and merge existing query parameters more carefully, especially if fragments or non-HTTP schemes are involved.


Adding domain-specific validation

A generic builder is useful, but real APIs often need stronger rules. For example, a search endpoint might allow only certain sort fields or require that min_price and max_price be positive.

You can encode these rules in dedicated methods:

impl QueryBuilder {
    pub fn add_sort(&mut self, field: &str, descending: bool) -> Result<&mut Self, QueryError> {
        match field {
            "created_at" | "name" | "price" => {
                self.add("sort", field)?;
                self.add_bool("desc", descending)?;
                Ok(self)
            }
            _ => Err(QueryError::EmptyValue), // replace with a dedicated error in real code
        }
    }
}

A better production design would introduce a separate error variant such as InvalidSortField(String). The key idea is that the builder can become a validation boundary for your domain, not just a string formatter.


Testing the output

Because query strings are easy to get wrong, tests are essential. Focus on encoding, ordering, and validation.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn encodes_spaces_and_special_characters() {
        let mut builder = QueryBuilder::new();
        builder.add("q", "rust & safety").unwrap();

        assert_eq!(builder.to_query_string(), "q=rust%20%26%20safety");
    }

    #[test]
    fn preserves_insertion_order() {
        let mut builder = QueryBuilder::new();
        builder.add("a", "1").unwrap();
        builder.add("b", "2").unwrap();
        builder.add("a", "3").unwrap();

        assert_eq!(builder.to_query_string(), "a=1&b=2&a=3");
    }

    #[test]
    fn rejects_empty_key() {
        let mut builder = QueryBuilder::new();
        let err = builder.add("", "value").unwrap_err();
        assert_eq!(err, QueryError::EmptyKey);
    }

    #[test]
    fn rejects_invalid_range() {
        let mut builder = QueryBuilder::new();
        let err = builder.add_range("price", 20, 10).unwrap_err();

        assert_eq!(err, QueryError::InvalidRange { min: 20, max: 10 });
    }
}

These tests are small, but they protect the behavior that matters most in practice.


Best practices for production use

When you adapt this pattern to a real codebase, keep these guidelines in mind:

  • Validate early: reject invalid keys and values before rendering.
  • Preserve order: stable output makes debugging and testing easier.
  • Prefer typed helpers: use add_int, add_bool, and domain-specific methods for common fields.
  • Avoid silent overwrites: repeated keys are often meaningful in query strings.
  • Keep rendering separate from validation: building and formatting are distinct responsibilities.
  • Use explicit errors: domain-specific error variants improve diagnostics.

If your API has many fixed parameters, consider wrapping QueryBuilder in a higher-level type such as SearchQuery or ListUsersQuery. That way, the compiler can enforce required fields and valid combinations before the query is even rendered.


Extending the pattern

Once the core builder is in place, you can extend it in several directions:

  • Typed enums for sort direction or status filters
  • Serde integration to serialize small request structs into query parameters
  • URL parsing to merge existing query strings from a base URL
  • Canonicalization for signature generation or cache keys
  • Feature-gated helpers for optional API capabilities

The important part is to keep the core representation simple. A vector of owned key-value pairs is easy to inspect, easy to test, and flexible enough for most request-building tasks.


Learn more with useful resources