Boundary Parsing in Rust


In this post

A network request, configuration file, command-line argument, or message from another process arrives as text or bytes. It is not yet a value that the rest of the program can safely use.

This work is boundary parsingParsing, syntax analysis, or syntactic analysis is a process of analyzing a string of symbols, either in natural language, computer languages or data structures, conforming to the rules of a formal grammar by breaking it into parts. The term parsing comes from Latin pars (orationis), meaning part.Read on Wikipedia →: turning a less structured representation into a more structured Rust value at the edge of a program. The earlier post on modeling data with Rust’s type system introduced this boundary. The Smart Constructors post showed how the resulting types can keep their rules behind private fields.

Parsing is the first domain boundary

Consider an order line:

buy 250 ACME

A parser can turn it into an Order with a side, a checked quantity, and a checked symbol. If the line is malformed, it returns an error and no order is created.

There are three decisions:

  1. Syntax: does the input have three fields separated by ASCIIASCII, an acronym for American Standard Code for Information Interchange, is a character encoding standard for representing a particular set of 95 (English-language–focused) printable and 33 control characters – a total of 128 code points. The set of available punctuation had significant impact on the syntax of computer languages and text markup. ASCII hugely influenced the design of character sets used by modern computers; for example, the first 128 code points of Unicode are the same as ASCII.Read on Wikipedia → whitespace?
  2. Meaning: is buy a known side, is 250 an allowed quantity, and does ACME follow the symbol rule?
  3. Construction: can these values be put into an Order without creating an invalid combination?

The parser owns the line’s shape. Quantity owns its range. Symbol owns its character rule. Order owns the relationship between its fields. This is the practical meaning of parse, don’t validate: the rest of the program receives a value that already carries the result of the checks.

Parse the complete input

Rust’s FromStr trait is the usual interface for parsing an owned value from a string slice. It returns Result<Self, Self::Err> and allows callers to write text.parse::<Order>().

The following example assumes that Order has side, quantity, and symbol fields; OrderQuantity is the validated type from the Smart Constructors post, and Symbol is another validated domain type with a checked TryFrom<&str> implementation. Only the boundary logic is shown here:

use std::str::FromStr;

#[derive(Debug)]
enum OrderParseError {
    Fields,
    Side,
    Quantity,
    Symbol,
}

impl FromStr for Order {
    type Err = OrderParseError;

    fn from_str(text: &str) -> Result<Self, Self::Err> {
        let mut fields = text.split_ascii_whitespace();
        let (Some(side), Some(quantity), Some(symbol), None) = (
            fields.next(), fields.next(), fields.next(), fields.next(),
        ) else {
            return Err(OrderParseError::Fields);
        };

        let side = match side {
            "buy" => Side::Buy,
            "sell" => Side::Sell,
            _ => return Err(OrderParseError::Side),
        };
        let quantity = quantity
            .parse::<OrderQuantity>()
            .map_err(|_| OrderParseError::Quantity)?;
        let symbol = Symbol::try_from(symbol)
            .map_err(|_| OrderParseError::Symbol)?;

        Ok(Self { side, quantity, symbol })
    }
}

The fourth next must return None. That is the complete-input check: buy 250 ACME extra is rejected instead of being accepted as a valid prefix. The parser delegates quantity and symbol validation instead of repeating those rules.

The parser also makes the failure stage visible. buy 250x ACME is not a number. buy 1000001 ACME is a number outside the quantity range. buy 250 acme violates the symbol rule. These cases should not all become “no order” if the caller needs to explain the problem.

Let each type own one rule

The Order parser decides how fields are separated and which field represents the side. It delegates the other rules to Quantity and Symbol. A JSON or database adapter should use the same conversions rather than writing another range or character check.

This keeps a rule in one place. If the maximum quantity changes, the change belongs to Quantity; the line parser does not need to change. If a second format creates an order, it can call the same TryFrom implementations.

Deserialization is another construction boundary. With Serde’s try_from container attribute, the deserializer reads a raw representation and then calls TryFrom. The unchecked representation therefore follows the same validation path as text input.

Make text and bytes explicit

Whitespace is part of the format. This example accepts ASCII whitespace between fields and around the line because split_ascii_whitespace removes it while splitting. A fixed-width protocol may require every byte to appear exactly as written. The parser should choose deliberately instead of trimming every input.

Input from a file or socket often arrives as bytes. Rust strings are valid UTF-8UTF-8 is a character encoding standard used for electronic communication. Defined by the Unicode Standard, the name is derived from Unicode Transformation Format – 8-bit. As of 2026, almost every webpage (99.1%) is transmitted as UTF-8.Read on Wikipedia →, so convert the bytes once when the format is text:

#[derive(Debug)]
enum OrderInputError {
    InvalidUtf8(std::str::Utf8Error),
    Parse(OrderParseError),
}

fn parse_order_bytes(bytes: &[u8]) -> Result<Order, OrderInputError> {
    let text = std::str::from_utf8(bytes)
        .map_err(OrderInputError::InvalidUtf8)?;
    text.parse().map_err(OrderInputError::Parse)
}

OrderInputError keeps the UTF-8 and order-parsing failures separate. str::from_utf8 validates the bytes and returns a borrowed &str without allocating another string. String::from_utf8_lossy replaces invalid bytes with U+FFFD, the replacement character; use it only when replacement is part of the format rather than a way to hide malformed input.

A binary protocol can keep its framing as &[u8] and validate UTF-8 only for fields defined as text. The Rust Book’s discussion of strings distinguishes bytes, Unicode scalar values, and user-visible grapheme clustersA character is a semiotic sign, symbol, grapheme, or glyph – typically a letter, a numerical digit, an ideogram, a hieroglyph, a punctuation mark or another typographic mark.Read on Wikipedia →. A parser should work at the level required by its format.

Choose owned or borrowed data

FromStr returns an owned value. It has no lifetime parameter connected to its input, so the returned type cannot safely keep a reference to the source string. The Symbol above owns a String for this reason.

Splitting can still borrow temporary fields. A helper such as Fields<'a> can hold &'a str values while the original input remains alive. Use that design when the parsed view does not need to escape the input buffer. Use owned fields when the value must be stored or sent to another part of the program.

When the grammar grows

Splitting and standard conversions are enough for a small flat format. Quoted fields, escapes, nested expressions, comments, and detailed error positions usually need a parser library.

A parser combinatorIn computer programming, a parser combinator is a higher-order function that accepts several parsers as input and returns a new parser as its output. In this context, a parser is a function accepting strings as input and returning some structure as output, typically a parse tree or a set of indices representing locations in the string where parsing stopped successfully. Parser combinators enable a recursive descent parsing strategy that facilitates modular piecewise construction and testing. This parsing technique is called combinatory parsing.Read on Wikipedia → library such as nom or winnow can express a larger grammar as smaller parsers. It does not replace domain types: the parser can recognise a number, while Quantity still decides whether that number is allowed.

The outermost parser should still require complete input. nom provides all_consuming for this purpose. In the small example, the same rule is the final None pattern.

The useful boundary is the one that turns uncertain input into a value with a clear contract. After it succeeds, the rest of the program can work with Order instead of parsing the same string again.

<— back to rust