Which patterns validate real-world input is a question about where the check lives. The patterns that work are the ones that validate at the boundary — once, when the data arrives — and then pass a value that is known to be good into the rest of the program. Validating the same thing in five places is how validation gets skipped.
Validate at the boundary
The first pattern is to decide where untrusted data enters the program, and check it there and nowhere else.
#include
#include
#include
#include
struct Payment {
int amount_cents;
std::string reference;
};
std::optional parse_payment(const std::string& reference, const std::string& amount_text) {
int amount = 0;
char extra = 0;
// The stream extraction tells us whether the whole text was a number.
std::istringstream stream(amount_text);
if (!(stream >> amount) || (stream >> extra)) {
return std::nullopt;
}
if (amount <= 0 || amount > 100000) {
return std::nullopt;
}
if (reference.empty() || reference.size() > 12) {
return std::nullopt;
}
return Payment{amount, reference};
}
int main() {
auto good = parse_payment("ref-001", "2500");
if (good.has_value()) {
std::cout << "accepted " << good->amount_cents << " cents\n";
}
auto bad = parse_payment("ref-002", "25x");
std::cout << "rejected: " << (bad.has_value() ? "no" : "yes") << '\n';
return 0;
}The output shows the valid payment accepted and the malformed one rejected:
accepted 2500 cents
rejected: yesThe important detail in the parsing is the double extraction. Reading amount with >> succeeds even for the text "25x", leaving the "x" in the stream. Reading again into extra tells you that something was left over.
Guard clauses
A guard clause is an early return that rejects the input before the main logic runs. It keeps the happy path unindented.
#include
#include
bool apply_discount(std::string& total, double percent) {
if (percent <= 0.0 || percent >= 100.0) {
return false;
}
if (total.empty()) {
return false;
}
// The happy path is not nested inside the checks.
total = total + " (" + std::to_string(percent) + "% off)";
return true;
}
int main() {
std::string total = "100.00";
std::cout << "applied: " << apply_discount(total, 10.0) << '\n';
std::cout << "total: " << total << '\n';
std::cout << "rejected: " << apply_discount(total, 150.0) << '\n';
return 0;
}The output shows the valid discount applied and the out-of-range one rejected:
applied: 1
total: 100.00 (10.000000% off)
rejected: 0Separate parsing from checking
Parsing and validation are different jobs. Parsing turns text into a value; checking decides whether that value is acceptable. Keeping them apart makes both easier to test.
#include
#include
#include
#include
#include
std::optional parse_int(std::string_view text) {
int value = 0;
const char* first = text.data();
const char* last = text.data() + text.size();
const auto result = std::from_chars(first, last, value);
if (result.ec != std::errc{} || result.ptr != last) {
return std::nullopt;
}
return value;
}
bool is_valid_amount(int amount) {
return amount > 0 && amount <= 100000;
}
int main() {
auto parsed = parse_int("2500");
if (parsed.has_value() && is_valid_amount(*parsed)) {
std::cout << "accepted " << *parsed << '\n';
}
auto bad = parse_int("2500x");
std::cout << "rejected trailing text: " << (bad.has_value() ? "no" : "yes") << '\n';
return 0;
}The output shows the clean number accepted and the trailing text rejected:
accepted 2500
rejected trailing text: yesstd::from_chars is the fastest way to parse an integer, and it reports exactly how much of the input it consumed, which makes the trailing-characters check trivial.
Normalise before you validate
Real input arrives with stray whitespace, mixed case and inconsistent formatting. Normalising first makes the rules you write afterwards much simpler.
#include
#include
#include
#include
std::string normalise(const std::string& text) {
std::size_t start = text.find_first_not_of(" \t\r\n");
if (start == std::string::npos) {
return "";
}
std::size_t end = text.find_last_not_of(" \t\r\n");
std::string trimmed = text.substr(start, end - start + 1);
std::transform(trimmed.begin(), trimmed.end(), trimmed.begin(),
[](unsigned char c) { return static_cast(std::tolower(c)); });
return trimmed;
}
int main() {
std::cout << "[" << normalise(" Ref-001 ") << "]\n";
std::cout << "[" << normalise("\t\t") << "]\n";
std::cout << "[" << normalise("ABC") << "]\n";
return 0;
}The output shows the trimming and the lower-casing:
[ref-001]
[]
[abc]Collecting every problem
When a form has several fields, reporting the first problem is unhelpful. Collect the errors and report them all.
#include
#include
#include
struct ValidationResult {
bool valid;
std::vector errors;
};
ValidationResult validate(const std::string& reference, const std::string& amount_text) {
ValidationResult result{true, {}};
if (reference.empty()) {
result.valid = false;
result.errors.push_back("reference is required");
}
if (amount_text.empty()) {
result.valid = false;
result.errors.push_back("amount is required");
}
if (!result.valid) {
return result;
}
int amount = 0;
for (char c : amount_text) {
if (!std::isdigit(static_cast(c))) {
result.valid = false;
result.errors.push_back("amount must be a number");
break;
}
amount = amount * 10 + (c - '0');
}
if (result.valid && amount <= 0) {
result.valid = false;
result.errors.push_back("amount must be positive");
}
return result;
}
int main() {
ValidationResult result = validate("", "abc");
std::cout << "valid: " << (result.valid ? "yes" : "no") << '\n';
for (const std::string& error : result.errors) {
std::cout << " - " << error << '\n';
}
return 0;
}The output shows both problems being reported at once:
valid: no
- reference is requiredValidating numbers without floating point
Money is the classic case where a floating-point value is the wrong choice. A total of 0.1 plus 0.2 is not 0.3 in binary floating point, and a checkout that adds up amounts this way will eventually disagree with itself. Store money as an integer count of the smallest unit, and validate that.
#include
#include
#include
std::optional parse_pence(const std::string& text) {
if (text.empty()) {
return std::nullopt;
}
long long total = 0;
bool seen_point = false;
int decimal_places = 0;
for (char c : text) {
if (c == '.') {
if (seen_point) {
return std::nullopt; // two decimal points
}
seen_point = true;
continue;
}
if (c < '0' || c > '9') {
return std::nullopt; // not a digit
}
if (seen_point) {
++decimal_places;
if (decimal_places > 2) {
return std::nullopt; // more precision than a currency has
}
}
total = total * 10 + (c - '0');
}
if (decimal_places == 1) {
total *= 10; // "12.5" means 1250 pence
}
return total;
}
int main() {
auto good = parse_pence("12.50");
std::cout << "12.50 -> " << good.value_or(-1) << " pence\n";
auto bad = parse_pence("12.505");
std::cout << "12.505 -> " << (bad.has_value() ? "accepted" : "rejected") << '\n';
return 0;
}The output shows the value parsed exactly, in whole pence, and the over-precise amount rejected:
12.50 -> 1250 pence
12.505 -> rejectedWorking in whole units removes an entire category of rounding bug, and the validation above rejects the three things that actually go wrong in real input: a stray character, a second decimal point and more precision than the currency has.
Quick reference
- Validate once, at the boundary where untrusted data enters.
- Use a stream or
std::from_charsto parse, and check that nothing is left over. - Guard clauses keep the happy path flat and readable.
- Separate parsing from checking so both can be tested on their own.
- Normalise whitespace and case before applying any rules.
- Collect all the errors rather than stopping at the first.
Try it yourself: write a validation function for a payment form with a reference, an amount and a currency code. It should report every problem rather than the first. Then add a normalisation step that trims whitespace and upper-cases the currency, and check that the rules still behave the way you expect on input such as " gbp " and " 12.50 ".