5  Parse, Don’t Validate: Boundaries and Retained Knowledge

5.1 Keep the result of checking

Alexis King’s “Parse, don’t validate” is not merely advice to check early (King 2019). It asks what representation remains after the check. A predicate can report that a value is acceptable and then discard the reason. A parser returns a value whose representation records what was established.

King’s contrast is usefully concrete. A function with the shape validateNonEmpty :: [a] -> () inspects a list, fails when it is empty, and returns no evidence when it succeeds. A later consumer receives the same [a] and must trust a convention or ask the same question again. parseNonEmpty :: [a] -> NonEmpty a performs the same inspection but returns a representation that retains the result. The important difference is not whether a check ran; it is whether the program kept what the check learned.

Two common pathologies follow when it does not. Shotgun parsing scatters recognition and rejection across the processing pipeline, so code begins acting on input before deciding what the input means and may discover a problem only after side effects or expensive work. Boolean blindness compresses a richer fact into TRUE or FALSE: the bit records that some predicate succeeded but carries neither the established structure nor the reason it matters. Both pathologies force downstream code to reconstruct knowledge the boundary already possessed.

In R, this is a runtime discipline rather than a compile-time proof:

A boundary accepts uncertain input and returns a value with a narrower, documented meaning, or it reports why no such value can be produced.

An S7 object can therefore act as a construction certificate, but only within a stated boundary. It certifies:

  • the property and class invariants visible when it was constructed;
  • the aggregate invariants visible to the aggregate root, if construction passed through that root;
  • continued validity under the update paths the API promises to support.

It does not certify facts that require unavailable context, laws never encoded, or the future contents of reference-valued payloads. Low-level mutation, unchecked validation escape hatches, and a mutable environment stored inside an otherwise valid object can all invalidate stronger claims. A getter’s result also needs its own contract; a declared property class does not by itself prove arbitrary computed output.

The useful claim is consequently precise: consumers may rely on the invariants established by the supported construction and transformation paths. Package authors make that claim credible by keeping those paths small, documented, and tested.

The progression from a primitive to a domain value makes the claim tangible. A character string "#336699" is only text; a successful parse_color() can return a Color whose channels are canonical finite integers in the declared range. Code that accepts Color need not repeatedly parse the hex spelling or re-check those channel laws. The object acts as a certificate for exactly those bounded facts. It is not a proof that an arbitrary getter is correct, that reference-valued internals never changed, or that every target renderer supports the color. Those stronger claims belong to other boundaries.

S7’s deferred-validation escape hatches make the boundary especially worth stating precisely. The detailed behavior of S7::valid_eventually() and S7::valid_implicitly(), including the difference between final validation and no final validation, is developed in Chapter 2. Here the architectural conclusion is enough: supported construction and update paths preserve the advertised certificate; unchecked internal paths transfer the proof obligation to their caller.

5.2 The same boundary move in several ecosystems

The representation move is broader than any one object system. These small examples all replace an unchecked number with a named positive budget, but their guarantees are not identical. Haskell and Rust can make the unchecked representation unavailable to statically typed consumers; Zod combines a runtime parser with a TypeScript brand; S7 gives R a nominal runtime value whose constructor and supported replacement paths enforce the contract (Glasgow Haskell Compiler contributors 2026; Rust Project Developers 2026; McDonnell 2026).

PositiveBudget <- S7::new_class(
  "PositiveBudget",
  properties = list(value = S7::class_double),
  validator = function(self) {
    value <- self@value
    if (length(value) != 1L || !is.finite(value) || value <= 0) {
      return("`value` must be one finite positive number")
    }
    NULL
  }
)

parse_budget <- function(x) {
  PositiveBudget(value = as.double(x))
}
newtype PositiveBudget = PositiveBudget Double

mkPositiveBudget :: Double -> Either String PositiveBudget
mkPositiveBudget x
  | isNaN x || isInfinite x || x <= 0 =
      Left "budget must be finite and positive"
  | otherwise = Right (PositiveBudget x)
struct PositiveBudget(f64);

impl TryFrom<f64> for PositiveBudget {
    type Error = &'static str;

    fn try_from(value: f64) -> Result<Self, Self::Error> {
        if value.is_finite() && value > 0.0 {
            Ok(Self(value))
        } else {
            Err("budget must be finite and positive")
        }
    }
}
import { z } from "zod";

const PositiveBudget = z
  .number()
  .finite()
  .positive()
  .brand<"PositiveBudget">();

type PositiveBudget = z.infer<typeof PositiveBudget>;

const parseBudget = (input: unknown): PositiveBudget =>
  PositiveBudget.parse(input);

The transferable lesson is not that S7 turns R into one of these languages. It is that a successful boundary should leave behind a representation that retains the distinction it just established.

5.3 Three different boundary decisions

Strict construction, tolerant parsing, and compilation answer different questions.

Strict construction creates a known domain value. RunSpecification() may require a bounded positive budget, a supported method, a valid candidate set, and a coherent relationship between its properties. Failure means no RunSpecification exists.

Tolerant parsing admits foreign or versioned input. It need not pretend that every input is known:

  • recognized and complete input becomes a known variant;
  • useful but unsupported input becomes an explicit UnknownSpecification;
  • incomplete input becomes a PartialSpecification carrying diagnostics;
  • input with no useful recoverable structure causes a condition.

Tolerance preserves knowledge only when uncertainty is represented. Returning a known class after silently dropping unknown fields is not tolerant parsing; it is lossy translation.

Compilation applies a target policy. A parser may preserve an unknown variant so that a document can be inspected or round-tripped, while compile_run() rejects it because the execution engine cannot assign it meaning. Parsing asks what can be represented; compilation asks what this target can execute.

This separation avoids two bad extremes: making every reader reject future content, and letting unknown content reach an executor as though it were understood.

The three decisions can form one continuous pipeline rather than three competing APIs. A tolerant reader first preserves what it can know. A strict constructor mints a RunSpecification only for a recognized, intrinsically lawful request. prepare() combines that specification with data and target context to produce PreparedRun, and execute() accepts only the prepared form. Unknown or partial parse variants remain inspectable and round-trippable, but compile_run() refuses them unless its target policy defines a lowering. Each arrow strengthens or preserves knowledge; none silently claims more than its inputs justify.

5.4 Constructors normalize; validators verify

Boundary functions may accept several input spellings, normalize names, coerce values under an explicit policy, and attach diagnostics. Constructors for known classes then receive canonical property values.

Validators should verify those canonical values, not repeatedly parse them. A property validator handles local laws such as length, range, membership, or a pattern. It returns NULL on success or a character vector containing one or more problems. A class validator handles relationships among the properties it can see and likewise may return multiple problems.

That cardinality matters. Reporting several independent violations in one construction attempt can be useful, and S7 validators are not limited to one message.

5.5 One authoritative definition, several uses

A rule may be enforced or exposed in several places without having several independent meanings. A declared bound might drive:

  • an S7 validator;
  • a JSON Schema projection;
  • input-widget limits;
  • reference documentation;
  • a fast boundary check with a more local error.

The goal is one authoritative definition, not one execution. Projections and duplicated enforcement can drift or contain bugs, so agreement is a test obligation rather than an automatic guarantee. When a rule cannot be projected faithfully, name the authoritative form and document the weaker projection.

This changes how to use a checking ladder. The ladder identifies the smallest boundary with enough information to state a rule coherently:

  1. an external conformance authority for a foreign wire contract;
  2. a parser for coercion, version handling, and unknown input;
  3. a property class and validator for one canonical property;
  4. a class validator for relationships within one object;
  5. an aggregate root for references and relationships among contained objects;
  6. an operation for contextual requirements and legal transitions;
  7. an internal assertion for states believed to be impossible.
flowchart TB
  Wire["External conformance"] --> Parse["Parsing and normalization"]
  Parse --> Property["Property class and local law"]
  Property --> Entity["Relationships within one object"]
  Entity --> Aggregate["References across an aggregate"]
  Aggregate --> Context["Contextual preparation or transition"]
  Context --> Assertion["Internal assertion"]
Figure 5.1: Place each rule at the first boundary with enough information to state it coherently.

The ladder in Figure 5.1 is about information visibility, not merely nesting. A child cannot certify a sibling relationship it cannot see. A root should not absorb a requirement that also needs a dataset, host policy, database catalog, or other external authority.

It is also a defense against shotgun parsing. The request enters through one parser, local laws become properties of RunSpecification, aggregate laws are checked where the complete request is visible, and contextual laws are resolved by prepare(). By the time execution begins, each question has one named home and a representation records its answer.

5.6 Contextual preparation

Consider a computation request:

loose input
    -> RunSpecification
    -> prepare(specification, dataset)
    -> PreparedRun
    -> execute()

RunSpecification can establish intrinsic facts:

  • budget is a positive scalar within the package limit;
  • method is a supported value;
  • candidate values are represented explicitly rather than inferred from vector length;
  • local properties obey a cross-property rule.

It cannot establish that selected columns exist in a later dataset, that a candidate count fits the number of available features, or that case weights match the row count. Those requirements become checkable only when both the specification and dataset are present.

prepare() is therefore not a second constructor that repeats validation. It is a contextual operation that combines already-established intrinsic facts with dataset facts and returns a stronger value. PreparedRun can carry the resolved columns, normalized candidate plan, data fingerprint, and any diagnostics or provenance needed by execution.

This is the temporal rule demonstrated particularly clearly by the public design of rtemis: check as early as possible, as late as necessary. Declaration errors whose inputs are already available can fail when a property specification is created. Configuration errors can fail before an expensive training run. Data-bound requirements—candidate counts relative to features, selected names relative to columns, or weights relative to rows—must wait until the data exists, but should run immediately then. Deferral is not omission. The future requirement is declared early and discharged at the first boundary with enough information.

The downstream payoff is observable. execute() no longer:

  • rechecks scalar shape and local ranges;
  • guesses whether a vector denotes one value or several candidates;
  • repeatedly looks up or validates column names;
  • discovers an impossible request after expensive work has begun.

The representation records that preparation occurred. If the dataset can change, the contract must say whether PreparedRun owns a snapshot, records an identity/version to check, or must be rebuilt. Construction cannot certify an external resource forever.

5.7 Supported transformations and cost

Property and class validators are hot-path code because supported property replacement revalidates. Keep them pure and inexpensive. Multi-property invariants require grouped replacement, such as S7::set_props(), so valid transitions are not rejected at an invalid intermediate state.

Bulk operations may defer validation and validate the final value once, but an unchecked path transfers the proof obligation to its caller. Such paths are implementation techniques, not public evidence of validity.

Every exported transformation should state its postcondition:

  • does it return another intrinsically valid RunSpecification?
  • does changing a context-sensitive field invalidate preparation?
  • does it preserve unknown foreign content?
  • can reference-valued contents change behind the object’s back?

Construction certificates remain useful only when their supported update paths preserve the advertised meaning.

5.8 Conditions, diagnostics, and outcomes

Conditions and values serve different control-flow contracts.

  • Use classed conditions for contract violations, impossible requests, and failures that should unwind unless handled.
  • Store diagnostics on unknown or partial parse variants when callers need to inspect and preserve them as data.
  • Use an outcome family when success, rejection, cancellation, or failure is an expected result of a domain operation.

The conversion point should be explicit. A batch runner might catch selected conditions around one task and produce a TaskFailed outcome, while programmer errors continue to propagate. Chapter 5 defines the composition policy for those outcomes; merely defining variant classes does not decide whether failures short-circuit or accumulate.

The distinction is easiest to understand narratively. If a caller asks for a negative budget, no lawful request exists: the boundary signals a classed condition and unwinds. If a remote job was accepted but later timed out, TaskFailed may be an ordinary result that a batch report must retain beside completed tasks. A warning can describe a recoverable normalization, while a diagnostic attached to PartialSpecification explains preserved uncertainty. The same words—error, warning, failure—are not interchangeable channels. Choose according to the control-flow promise callers need, then provide one deliberate adapter where a selected condition becomes an outcome value.

For caller-facing conditions, cli::cli_abort() can provide structured, classed errors while rlang::caller_arg() and rlang::caller_env() preserve the user’s argument name and call context. Validator messages should remain short statements of violated laws.

5.9 Make the retained knowledge testable

Tests should establish both rejection and simplification:

  • unlawful known values fail at the boundary with useful diagnostics;
  • tolerant parsing returns the correct known, unknown, or partial variant;
  • compilation rejects variants unsupported by its target policy;
  • supported updates preserve intrinsic invariants;
  • preparation rejects contextually impossible requests before execution;
  • execution accepts PreparedRun without repeating earlier checks;
  • projections of an authoritative rule agree on representative and boundary cases.

Example tests establish named cases, but generated and property-based tests exercise the laws across a much larger input space. Generate lawful RunSpecification values and check that supported updates preserve validity; generate loose inputs and check that parse/render normalization is idempotent; generate model values and check round-trip equivalence under the relation the API actually promises. Shrinking is especially valuable because it reduces a failed law to a small counterexample rather than a large random fixture.

The public s7contract package develops this direction explicitly: interfaces name required generics, traits register implementations, and generative laws test the behavioral part of a contract that a dispatch table cannot see. Using that package is optional; writing executable laws is not. An interface is never only a list of method names.

The central success criterion is not the number of validators. It is how much less uncertainty downstream code must reconstruct. When the boundaries are doing their jobs, a calm middle emerges: core functions accept domain values, perform domain work, and return domain values. They do not contain a haze of is.character() checks, repeated parsing, and defensive branches. Suspicion is concentrated at the edges, where errors can be local and informative, and the middle is free to express the actual computation.