Validation often starts as a small check near the edge of a program. As the system grows, the same fact gets checked again in handlers, services, helpers, and background jobs because none of those places can tell whether the value they received has already been validated.
The result is defensive code everywhere and uncertainty about what a function may safely assume.
A useful design technique is to parse boundary data into a trusted type. Instead of checking a raw value and then continuing to pass that raw value around, convert it into a representation that can exist only after the required checks succeed. Core code receives that representation and can rely on the facts it expresses.
This article explains the mental model, where it helps, where it does not, and how to avoid turning simple validation into unnecessary type machinery.
Validation answers a question; parsing changes what you have
Suppose an application receives a quantity as text:
"12"A validation function might answer whether the text is acceptable:
isValidQuantity("12") -> trueAfter that call, however, the program still has the same string. A later function that receives "12" cannot know whether validation happened. It may reasonably validate again.
Parsing produces a different result:
parseQuantity("12") -> Quantity(12)If parsing succeeds, the program no longer has merely a string that happened to pass a check. It has a Quantity, whose construction rules can guarantee the properties the rest of the program needs.
The useful mental model is:
At a boundary, turn uncertain data into a representation that carries the facts you have established.
The boundary might be an HTTP request, configuration file, command-line argument, message, imported record, or another subsystem. The technique is not tied to any particular transport or programming language.
Start with one fact worth preserving
Assume an ordering system accepts a quantity that must be a whole number from 1 through 100.
A design that keeps passing a primitive integer may look like this:
function createOrder(quantity):
if quantity < 1 or quantity > 100:
return error("invalid quantity")
return reserveStock(quantity)
function reserveStock(quantity):
if quantity < 1 or quantity > 100:
return error("invalid quantity")
...The second check may look redundant, but removing it creates a question: is reserveStock callable from somewhere that did not run createOrder first?
The problem is not validation itself. The function signature does not communicate the validated fact.
Introduce a value whose construction enforces the rule:
function parseQuantity(raw):
number = parseInteger(raw)
if number is parse_error:
return error("quantity must be a whole number")
if number < 1 or number > 100:
return error("quantity must be between 1 and 100")
return Quantity(number)Core functions can now state what they actually require:
function reserveStock(quantity: Quantity):
...The parsing step establishes the range rule once. reserveStock consumes the result of that decision instead of rediscovering it.
This is simplified pseudocode. In a production implementation, the language may provide constructors, private fields, opaque types, smart constructors, modules, or other mechanisms for controlling how a value is created. The design goal is the same: ordinary callers should not be able to create a Quantity that violates the rule the type claims to represent.
A trusted type is only as strong as its construction boundary
Renaming an integer to Quantity does not create a guarantee by itself.
Consider this design:
record Quantity {
value: integer
}If any caller can write Quantity(-5), the type does not preserve the range invariant. Every consumer must still distrust it.
The important part is controlled construction:
function Quantity.create(number):
if number < 1 or number > 100:
return error
return Quantity(number)The exact access-control mechanism varies by language, but the principle is general. If a type represents a validated fact, construction paths must preserve that fact.
Mutation matters for the same reason. A valid Quantity(12) stops being trustworthy if callers can later assign -5 to its internal value. Either make the relevant state immutable or ensure every mutation preserves the invariant.
A trusted representation therefore depends on two things: valid construction and invariant-preserving updates.
Parse at the point where uncertainty enters
The technique is most useful when conversion happens near the boundary.
Imagine a request handler receiving this input:
{
"product_id": "P-1042",
"quantity": "12"
}A clean flow separates boundary concerns from core behavior:
function handleRequest(request):
quantity = parseQuantity(request.quantity)
if quantity is error:
return badRequest(quantity.message)
result = ordering.placeOrder(request.product_id, quantity)
return responseFrom(result)The handler understands that request data can be malformed. The ordering code receives a Quantity and does not need to know whether the original value came from JSON, a form, a queue, or a command-line tool.
This separation has a practical consequence. If another adapter later reads quantities from a CSV import, that adapter can perform the same conversion before calling the core operation. The core contract remains stable.
Do not interpret “parse at the boundary” as “put every business rule in the transport handler.” The handler can call domain-level parsing or construction functions. What matters is that uncertain external representations do not travel deep into code that expects established facts.
Preserve facts that remove meaningful branches
Not every validation check deserves a new type.
A type is valuable when preserving the fact simplifies downstream reasoning. Useful candidates often include values such as:
- a non-empty identifier with a defined format;
- a percentage constrained to an accepted range;
- a normalized email address if the application has a precise normalization policy;
- a date interval whose end is not before its start;
- a quantity constrained by a domain rule.
The benefit becomes visible in downstream code. Compare:
function calculatePrice(quantity):
if quantity <= 0:
return error
...with:
function calculatePrice(quantity: Quantity):
...If Quantity guarantees a positive value, the second function has one fewer possible state to consider. Tests for calculatePrice can focus on pricing behavior rather than repeatedly testing malformed quantities that the function cannot receive through its intended interface.
This does not eliminate validation tests. It moves them to the parser or constructor that owns the rule.
Distinguish structural validity from changing business policy
Some rules make good type invariants because they define what a value is. Other rules depend on context and can change while the value remains meaningful.
Suppose Quantity guarantees only that a quantity is between 1 and 100. A warehouse currently has 8 units in stock. The rule “requested quantity must not exceed current stock” should not normally be part of Quantity construction.
Why? Stock availability depends on which product is being ordered and on mutable system state. Quantity(12) is still a perfectly valid quantity even when one warehouse has only 8 units.
Keep contextual policy where the required context exists:
function reserveStock(product, quantity: Quantity):
available = inventory.availableFor(product)
if quantity.value > available:
return insufficientStock
...This distinction prevents trusted types from becoming containers for unrelated business decisions.
A useful test is to ask: if this rule becomes false, does the value itself become malformed, or is the value valid but unsuitable for this operation?
Malformed values are strong candidates for parsing or construction rules. Values that are merely unsuitable in a particular situation usually need normal business logic.
Keep error information at the boundary
Parsing can fail for different reasons, and callers often need enough information to respond appropriately.
For the quantity example, these inputs fail differently:
"many" -> not a whole number
"0" -> outside the allowed range
"101" -> outside the allowed rangeThe parser should return failure in a form appropriate to its callers. A user-facing adapter may need a specific message or error code. A lower-level constructor may only need to distinguish success from an invalid value.
Avoid making a core value type depend on HTTP status codes, UI labels, or another delivery mechanism. The type can report a domain-level reason, while the boundary translates that reason into the response its protocol requires.
Also avoid silently repairing input unless the repair is part of the documented contract. Trimming surrounding whitespace may be acceptable for some fields; changing an out-of-range quantity from 101 to 100 changes the caller’s request. Parsing should establish a clear meaning, not hide surprising transformations.
Do not confuse trusted with permanently true
A parsed value proves only the properties enforced by its construction and update rules.
If ProductId guarantees a syntactically valid identifier, it does not prove that the product currently exists. If AccountId was resolved to an existing account five minutes ago, that does not guarantee the account still exists now. If a file path was accessible when checked, permissions or filesystem state may change before the next operation.
This matters whenever a fact depends on external mutable state.
Do not encode a temporary observation as though it were an eternal type guarantee. Re-check conditions at the operation that depends on current state, especially when another process, request, or service can change that state between the check and the use.
Trusted types are strongest for properties intrinsic to the represented value or for snapshots whose meaning explicitly includes when they were established.
Avoid creating a type for every primitive
It is possible to apply this technique too aggressively.
If a value has no meaningful invariant, is used in one small scope, and cannot be confused with another value, a primitive may be clearer. Wrapping every string and integer can add constructors, conversion code, names, and navigation without reducing real uncertainty.
The trade-off is worthwhile when the type removes repeated checks, prevents accidental mixing of concepts, centralizes a stable invariant, or makes an important function contract visible.
It is less useful when the wrapper merely renames a primitive while preserving all the same possible states.
Start where bugs or repeated defensive checks already show that the distinction matters. You can introduce stronger representations incrementally rather than redesigning the whole model at once.
Migration can be gradual
Existing systems rarely allow every raw value to be replaced in one change.
A practical migration is to choose one boundary and one operation:
raw request
|
v
parseQuantity
|
v
placeOrder(Quantity)Change that operation and its immediate callers first. Keep conversion code at the edges of the migrated area. As more code accepts the trusted type, raw representations retreat toward system boundaries.
During migration, resist adding convenience functions that immediately unwrap the value and pass the primitive everywhere. Some unwrapping is unavoidable for storage, serialization, arithmetic, or integration with existing APIs, but widespread unwrapping removes the contract benefit you introduced the type to provide.
The aim is not to hide the underlying value. It is to keep the validated fact attached for as long as that fact helps downstream code reason correctly.
Use the technique when it changes who must be defensive
Parsing boundary data into trusted types changes responsibility.
Boundary code remains defensive because external data can be malformed. Constructors and parsers own the rules that define valid values. Core operations can then be narrower: they accept values that already satisfy those rules and focus on their own decisions.
That arrangement is useful when the same validation appears repeatedly, when function signatures hide important assumptions, or when raw values allow states the core should never need to handle.
Keep simpler validation when the value is local, the rule is contextual, or a wrapper would add more ceremony than clarity.
The practical takeaway is not “replace primitives with types.” It is more specific: when a validated fact matters beyond the place where you checked it, represent that fact so later code does not have to guess whether it is still allowed to rely on it.