4  Types and Meaning: Semantic Types, Structure, and Type-Driven Design

4.1 Three lenses on what a type is

Before designing with types it helps to hold three complementary definitions in mind.

A type is a set of values. integer between 0 and 255 names a set; nonempty string names a set. Under this lens, subtyping is subset-hood, a union type is a set union, and a validator is a membership predicate.

A type is a contract on operations. Knowing x is a Color tells you what you may do: read channels, convert to hex, blend. Under this lens, interfaces and generics define types as much as data layouts do — a “thing that can be serialized” is a type in the behavioral sense even if no class hierarchy says so.

A type is a name for meaning. EmailAddress and CustomerId may both be strings-as-sets, and support the same operations-as-contracts, yet naming them separately encodes knowledge: these are different kinds of thing, and substituting one for the other is a bug even when no operation would fail.

Static languages let a compiler exploit all three lenses. R distributes the work across constructors, property and class validation, generic dispatch, schemas, boundary checks, and tests. These mechanisms enforce different things, but they share a design move: make meaning explicit enough that consumers can rely on it.

4.2 Structural versus nominal — and where semantics enters

Type systems differ in what makes two types “the same,” and most languages mix several mechanisms. TypeScript object types and Go interfaces are prominent structural examples: compatibility can follow from the required shape without an explicit declaration. Java class identity and Haskell newtype declarations are prominent nominal examples: distinct declarations remain distinct despite similar representations. Those languages also contain exceptions and additional mechanisms, so the contrast is a design lens rather than a complete taxonomy.

R code can be written in a structurally permissive style: a function that reaches into x$field may work on anything exposing that component. That does not make S7 structurally typed. S7 dispatch and property-class checks use declared class identity and inheritance. Shape requirements such as scalar length, names, or required components are additional runtime contracts, usually expressed by properties and validators rather than inferred from structure. An S7 design can therefore combine nominal identity with explicit shape constraints without confusing the two.

The third notion — semantic typing — is the design payoff of nominal identity. The classic statically typed device is the newtype: wrap a primitive in a distinct type (CustomerId wrapping string) so that the compiler stops you from passing a customer id where an order id is expected. The wrapper adds no structure; it adds meaning and prevents an entire class of “stringly typed” defects where everything is a character vector and nothing stops the wrong one from arriving.

4.3 Semantic contracts and loose coupling

There is a systems-integration corollary that elevates semantic types from a convenience to an architectural principle. When two systems integrate by sharing structure — an agreed record layout — every structural change in one forces coordination with the other; the coupling is total. When they integrate by sharing semantics — agreed atomic meanings like CustomerId, EmailAddress, FloodZoneClass — each system arranges those atoms in whatever local structure suits it, and a mapping layer mediates. Structures then evolve independently while data remains meaningfully exchangeable, because the meanings are the stable contract.

(This idea is developed explicitly in data-integration languages like Taxi, where type CustomerId inherits String declarations are shared across systems precisely so models do not have to be.)

The general lesson for a multi-dialect domain is to seek stable shared meanings without assuming they are known in advance. Sketch representative documents and operations for more than one dialect, then extract the semantic concepts that survive the comparison. Modeling one dialect as universal couples every other dialect to its incidental structure; designing an “uber-schema” before seeing real consumers is the opposite error.

4.4 A running concept: color in a style document

To keep the rest of the chapter concrete, consider a Color used by a style document. At first it appears to be “four numbers”: red, green, and blue channels in [0, 255], plus alpha in [0, 1]. That description already hides several decisions. Are channels integers or merely integerish numerics? Is alpha stored when omitted, and who chooses its default? Can a color travel independently of the style? Must callers blend colors, render them as hex, or preserve the exact spelling read from a wire document?

Those questions are not implementation trivia. They determine whether color is a repeated property recipe, a nominal value, or an incidental wire shape; which rules are local and which relate several channels; and whether serialization is a projection of the domain value or the source of truth for it. The sections that follow revisit the same concept as each design layer comes into view.

4.5 A practical property and contract inventory

Before choosing a class graph, inventory the questions each concept raises. This is a design worksheet, not a requirement to build a property framework first. For the running color, it turns “four numbers” into the actual decisions a representation must retain:

  • Representation: What R class and shape are canonical? Is the value a scalar, vector, record, alternative, or collection? A color could be a length-four numeric vector or a record with named channels; the choice affects readability and projection.
  • Cardinality: What lengths are lawful? Are names, order, or uniqueness significant? An unnamed vector makes channel order load-bearing, while named properties make the association explicit.
  • Membership and bounds: Is the value drawn from an enum, literal set, interval, pattern, or other locally decidable set? Each RGB channel has a local integer and range law; alpha has a different range.
  • Presence: Must the resulting object contain it? Must the caller supply it? May the stored value be NULL or NA? Are zero-length and empty values meaningful? A style may require a color while allowing callers to omit alpha.
  • Default ownership: Does omission mean an R-owned domain default, a value supplied by an external authority, or a caller request to materialize a default later? If opaque is the domain default, R can store alpha = 1; if an external renderer chooses opacity, materializing 1 would invent knowledge the model does not own.
  • Relationships: Which rules mention several properties of one object, several contained objects, or external context?
  • Lifecycle: Is this an editable value, a read-only snapshot, or part of a reference owner?
  • Operations: Which verbs consume or produce it, what do they preserve, and how do they fail? blend(), as_hex(), and contrast calculations may justify color as an independent dispatchable concept.
  • Projection: How does it serialize, and which distinctions among omission, null, unknown, and empty must survive? A wire format may accept a hex string, an array, or an object even if the domain stores one canonical form.

The inventory determines rule placement. Intrinsic shape, cardinality, membership, bounds, and local presence policy belong in property contracts. Relationships among one object’s properties belong in its class validator. Cross-object references belong at an aggregate boundary. Rules needing data, metadata, resources, or authority belong in a named contextual operation. When one slot accepts alternatives, an S7 union may name primitive classes, domain classes, or both; a union describes accepted values, not a closed variant family. For Color, channel bounds are property contracts, the relationship among channels belongs to the Color class if one exists, and whether a foreground color meets an accessibility threshold is contextual to its background and belongs in an operation with both values.

4.6 Three carriers of semantics in S7

S7 gives three mechanisms for making meaning first-class, in ascending order of weight. The running color can stop at any tier, depending on how far its meaning must travel.

1. A reused property definition. A property object is a value; define it once, use it in many classes:

prop_email <- S7::new_property(
  class = S7::class_character,
  validator = function(value) {
    if (length(value) != 1L) return("must be a single string")
    if (!grepl("^[^@[:space:]]+@[^@[:space:]]+$", value)) {
      return("must be an email address")
    }
    NULL
  }
)

Two classes using prop_email share the law and, by convention, the meaning. There is no new nominal type — a plain string still flows in and out — so nothing prevents cross-wiring two different string-valued properties. This is the cheap tier: shared validation, meaning by convention. Note the S7 convention that property validators do not mention the property’s name; S7 prepends it, which is exactly what makes one definition reusable across many properties.

The corresponding color design is a reused prop_rgb_channel plus a separate prop_alpha. This is enough when channels matter only in their containing style class and no consumer receives a color as a unit. It shares the local laws without claiming that an extracted integer is itself a color.

2. A class inheriting from a base type. S7::new_class("MarkdownText", parent = S7::class_character) creates a nominal type whose instances are still character vectors — the underlying data is reachable via S7::S7_data(), and with an S3 bridge the value can keep behaving like a string where that is wanted. Now dispatch can distinguish it, property classes can demand it, and accidental substitution fails loudly. ragnar’s MarkdownDocument is a published example: a length-one string with an @origin property, inheriting from character because a markdown document genuinely is text — the inheritance is honest, not decorative. This is the newtype tier: nominal identity at modest cost. Its limit is structure: the moment the concept has parts, packing them into a subclassed primitive with a heroic validator is fighting the system.

3. A composite class. When the concept has internal structure — a color with four channels, a range with two endpoints, an extent with bounds — it gets a class with properties, each carrying its own local law, plus a class validator for laws that relate them. This is the full tier: identity, structure, invariants, and a dispatch anchor.

Choosing the tier is a judgment about the concept, not the code: does it have parts? Does behavior dispatch on it? Must it be impossible to confuse with its underlying primitive? Escalate only when a “yes” appears.

The decisive distinction is whether meaning must travel independently. Reused channel properties can enforce that red, green, and blue are scalar bounded integers and alpha is a bounded scalar. A Color class is warranted when the four channels must remain related after extraction, when operations such as blend() or as_hex() dispatch on the concept, or when a color must not be confused with an arbitrary numeric vector. The property shares a contract in a containing position; the class gives the concept nominal identity and an independent life. This is not “more type safety” in the abstract. It is a direct answer to whether consumers manipulate channels or colors.

4.7 Type-driven design in a dynamic language

Type-driven design means making domain concepts, admissible states, relationships, and operations explicit in the values and contracts from which the program is composed. Its goal is not to imitate a static typechecker. Its test is whether the representation retains knowledge and makes consumers simpler.

Use an iterative workflow:

  1. Start from domain and consumers. Identify the decisions the program must make and the outputs, side effects, or external authorities involved.
  2. Sketch representative nouns and verbs. Model a few important values, variants, containment relationships, and operations directly.
  3. Inventory contracts. Record shape, cardinality, membership, presence, defaults, local constraints, and contextual requirements encountered in those sketches.
  4. Place each rule. Use properties, classes, aggregates, or contextual operations according to information visibility.
  5. Exercise the operations. Let methods expose missing distinctions, awkward variants, and laws the representation should preserve.
  6. Extract repeated recipes. Generalize property or validator machinery only after real repetition, then revisit both nouns and verbs.
flowchart LR
  Domain["Domain pressures"] --> Sketch["Representative nouns and verbs"]
  Sketch --> Inventory["Property and contract inventory"]
  Inventory --> Placement["Place rules by information boundary"]
  Placement --> Operations["Exercise operations and laws"]
  Operations --> Refine["Refine distinctions and representations"]
  Refine --> Sketch
  Operations --> Extract["Extract repeated machinery"]
Figure 4.1: Type-driven design is an iterative loop between domain meaning, representation, and operations.

Figure 4.1 emphasizes that the model and its operations mature together; neither is a one-way derivation from the other.

The color example shows why the loop matters. Starting from the noun alone might produce a four-field class. Exercising as_hex(color) then forces a decision about rounding, alpha inclusion, and canonical case. Exercising blend(foreground, background) reveals that alpha is semantically different from the RGB channels and that the result should again satisfy the Color contract. Asking for faithful round trips may reveal a second concern—the original wire spelling—that should not be confused with domain equality. Each verb either confirms the representation or exposes knowledge it failed to retain.

Construction can then act as evidence for the invariants visible at that boundary. It is not a universal certificate: unsupported mutation, shared reference payloads, and external facts outside the validator’s view require their own contracts.

4.8 Data-driven design, in two senses

“Data-driven” names two distinct and equally useful ideas, and conflating them causes confusion.

Design driven by data about the domain. Before abstracting, census the actual corpus: which shapes recur across a specification, which fields are required in practice, which enum values actually occur. Generalized machinery is then earned by observed repetition rather than speculation. A property-factory layer should contain the recipes real models reuse, not entries added “for completeness.” This restraint applies to infrastructure, not to naming an important concept that appears only once.

Behavior represented as data. Tables and specifications that drive computation: a classification table that generates both a style expression and a legend; a schedule that drives a state machine; quoted expressions composed into generated validator functions. This is the older Lisp/Unix sense — programs manipulating programs-as-data — and it reappears in Documents, Schemas, and Languages as the foundation of DSL and codegen work. When the specification is owned and semantic, validators, defaults, schemas, documentation, and interfaces can be deliberate projections from it. This removes independent definitions and makes agreement testable; it does not make generators infallible.

Candidate values show why retaining intent matters. A numeric vector alone does not say whether its elements are observations, bounds, or values to try during tuning. A CandidateValues concept can establish non-emptiness, element constraints, ordering or uniqueness policy, and the fact that the vector expresses a search choice. Once constructed, preparation and execution code no longer infer intent from length or naming conventions. Whether this concept is a validated property or a nominal class depends on whether it must travel independently or participate in dispatch.

4.9 The wire universe versus the domain universe

Documents live in an impoverished type universe. JSON has exactly six kinds: null, boolean, number, string, array, object. R’s universe is richer and messier: NULL and NA (distinct!), integer versus double, length-one vectors standing in for scalars, named lists versus unnamed lists, attributes. Designing a document model means designing the mapping between universes, and most of the notorious bugs live in four mismatches:

  • Number: JSON does not distinguish 8 from 8L. If a property demands S7::class_integer, wire input and casual users will supply doubles. Either accept S7::class_numeric plus an integerish validator, or coerce in the admission function — but decide once for that boundary.
  • Scalar: JSON has scalars; R has length-one vectors. Every “string” property needs a length-1 law somewhere, which is why scalar-ness is usually the first check in any property-validator foundation.
  • Nothing: JSON can omit a key, or set it to null; R has NULL and also NA. These are up to four distinct meanings (“never mentioned,” “explicitly cleared,” “unknown,” “not applicable”) flowing through two representations. Tony Hoare called null references his billion-dollar mistake; R doubled the budget. The only defense is an explicit, per-property presence policy.
  • Empty: an empty array, an empty object, and an absent key serialize differently and often mean different things ("layers": [] versus no layers key). Serializers need a stated omit-empty policy per field.

For the running example, the domain may store a canonical Color(red, green, blue, alpha) while one wire format accepts "#336699", another accepts c(51, 102, 153, 1), and a third emits named fields. Admission functions can normalize all three into the domain value, and serializers can project it back into the chosen dialect. If exact lexical round-tripping matters—such as preserving "#369" rather than emitting "#336699"—the original spelling is additional provenance that must be stored explicitly. It cannot be recovered from the normalized color.

4.10 Presence and default ownership

“Optional” conflates questions a property model should answer separately:

  • Must the resulting object contain the property?
  • Must the caller explicitly provide it?
  • May the stored value be NULL or NA?
  • Are a zero-length vector, empty string, list, or bag meaningful?
  • Does omission invoke a default?
  • Does serialization omit the field, emit null, or emit an empty value?
  • Who owns the default: R construction, an external authority, or a caller requesting later materialization?

A default should encode domain knowledge, not merely make construction stop complaining. Empty strings, zeroes, and sentinel values are not harmless when they mean “the model does not know”; they erase the distinction between omission and a meaningful value. If no lawful value follows from the domain, require the caller to supply one or represent absence explicitly.

Color makes default ownership visible. If the style language defines omitted alpha as fully opaque, alpha = 1 is domain knowledge and an R-owned default can record it immediately. If omission means “let the rendering engine choose,” storing 1 destroys a distinction the wire and runtime may care about. The model should then preserve omission—perhaps with NULL or a distinct deferred-default state—and materialize the value only when the authoritative engine or metadata is available. A convenient default is correct only when the model owns the choice.

The compact example is a document version restricted to 8. The document may require the field and always emit it, while the R constructor permits omission because it owns the default. The stored value may be non-null and restricted to one literal. Those statements are compatible: “required in the resulting document” does not imply “the caller must type it.” If wire input may contain 8, 8L, or another tolerated spelling, coercion belongs in the admission function; the literal property contract should judge only the canonical stored representation.

Collapsing these choices into optional = TRUE loses distinctions needed for patching, merging, and faithful round trips.

One S7 mechanic makes this especially concrete. In S7 0.2.2, a property with no explicit default receives the prototype of its declared class; for a union, that prototype comes from the union’s first member. Reordering a union can therefore change constructed values even though the set of accepted classes is unchanged. Treat member order as behavior when relying on the implicit default, or specify the default explicitly so order does not silently choose one.

4.11 Literals, enums, and identity

A recurring puzzle when modeling specifications: a field whose value is fixed ("version": 8, "type": "geojson"). JSON Schema calls it const; TypeScript calls it a literal type. Three modeling options exist, and the choice is a genuine design decision, not a technicality:

  • Class identity. The field is not state at all; it is what the class is. Model it as a computed (getter-only) property or inject it at serialization. Two instances of the class can never differ on it, and it is not a constructor argument.
  • Stored with a default and a closed validator. The field is state that happens to have one lawful value today — the spec may add values, or round-tripping must preserve exactly what the wire said.
  • Boundary-normalized. Accept lenient input (8, 8L, even "8") at the admission boundary and store the canonical form.

The two questions that decide it: can two instances of this class ever differ on this value? (If no, it is identity, not state.) And must a round-trip preserve the user’s supplied value verbatim? (If yes, it must be stored.) Type discriminators in tagged unions (“this is a geojson source”) are often class identity — the class is the tag — while version fields are judgment calls that depend on whether one class will represent multiple spec versions.

4.12 Refinements, for the statically minded

If you come from typed languages, the property validator is best understood as a runtime refinement type: { x : numeric | 0 <= x && x <= 255 }. The class system checks the base type; the refinement predicate narrows it. This framing explains the layering rule in Parse, Don’t Validate — base type and refinement are checked in that order, and S7 formalizes it by validating class before calling the property validator — and it explains why refinements should be pure and cheap: they run at every construction and replacement, like a type check would.