Skip to content

Type System Notes

This page explains the core JSON helper types in jsonpatchx/types.py.

The important idea is simple: JsonPatchX keeps its JSON types true to JSON, not to Python convenience behavior.

JSON Semantics

The helper family models JSON values with strict runtime behavior:

  • strings stay strings
  • booleans stay booleans
  • numbers are int or finite float
  • arrays are concrete list values
  • objects are concrete dict[str, ...] values

That means JsonPatchX does not quietly accept Python conveniences that would blur a PATCH contract.

Examples:

  • "2" does not coerce to 2
  • True is not treated as a JSON number just because Python makes bool a subtype of int
  • float("nan") and float("inf") are rejected
  • tuple-like containers are not accepted as runtime JSON arrays

Those constraints are deliberate. PATCH payloads are usually crossing trust boundaries, so the library prefers explicit rejection over clever coercion.

Helper Types

The core helper family is:

  • JSONBoolean
  • JSONNumber
  • JSONString
  • JSONNull
  • JSONArray[T]
  • JSONObject[T]
  • JSONScalar
  • JSONContainer[T]
  • JSONValue

JSONScalar is the union of the four scalar helpers.

JSONContainer[T] is the union of JSONArray[T] and JSONObject[T].

JSONValue is the full recursive JSON value type used when a field or API surface wants an actual JSON document or subdocument.

Type Aliases at Check Time, Pydantic Helpers at Runtime

The names in types.py are doing two jobs.

During static type checking, the scalar and container helpers are pleasant type aliases such as:

type JSONString = str
type JSONArray[T] = list[T]

At runtime, those same names become small Pydantic-aware helper classes with custom validation and JSON Schema behavior.

That split exists because plain aliases were not enough to get all three goals at once:

  • strict runtime validation
  • readable static types
  • stable and minimal OpenAPI output

So when types.py looks more complicated than the JSON domain itself, that is usually because the implementation is balancing those three requirements on purpose.

Published Schemas

The runtime helper classes are not there only for validation. They also shape the published schema surface.

This matters because some obvious-looking alternatives produce poor OpenAPI:

  • helper aliases can become noisy named schema components
  • replacing generated schema wholesale can hide useful field keywords
  • pushing strictness to the wrong level can make Pydantic accept values that a JSON contract should reject

One design goal here is that constraints layered on top of helper types should survive into the published schema instead of disappearing.

For example, Annotated[JSONNumber, Field(gt=4)] should still advertise that the value must be greater than 4.

In the current stack, that lower bound is preserved, but not in the desired OpenAPI form: it is emitted as Pydantic's gt: 4 metadata rather than normalized OpenAPI exclusiveMinimum: 4.

That is not unique to JSONNumber. Plain Annotated[int | float, Field(gt=4)] currently behaves the same way under the project's OpenAPI 3.1 output.

JSONValue is the clearest example. At runtime it validates against the full strict recursive JSON union, but its published JSON Schema is deliberately inlined as {} so helper internals do not leak into OpenAPI as a named component.

Contributors should treat that as part of the design, not an implementation accident.

Likewise, JSONBoolean | JSONNull currently renders as:

anyOf:
  - type: boolean
  - type: null

That is normal for OpenAPI 3.1. In older OpenAPI 3.0 tooling, the same concept often appeared as nullable: true on a base type instead.

JSONValue and JSONBound

JSONValue and JSONBound are related, but they are not interchangeable.

JSONValue is the actual Pydantic-aware JSON value model. Use it when runtime validation should accept only genuine JSON values with the strict rules above.

JSONBound is a typing bound for generics. It means “JSON-shaped enough to be a valid type parameter bound,” not “accepted as a runtime JSON payload.”

Examples:

  • JSONScalar is assignable to both JSONValue and JSONBound
  • JSONArray[JSONValue] is assignable to both JSONValue and JSONBound
  • JSONArray[JSONScalar] is only assignable JSONBound

If JSONPointer[T] was bound by JSONValue, JSONPointer[JSONArray[JSONScalar]] would be a type checker error. So T is bound by JSONBound instead, with the compromise that it's overly permissive. For example, JSONPointer[tuple[int]] is "type-safe."

For more about why it has to be this way, see Limitations in Python's Type System.

Missing Document Sentinel

MISSING is the runtime sentinel for “the document no longer exists.”

Operation Results

A custom operation that intends to delete the whole document should return MISSING, not None. None is JSON null. MISSING means document deletion.

This sentinel exists at the operation-result layer so composed operations can pass along “the document was deleted” without pretending that state is a JSON value. ReplaceOp, for example, literally implements root replacement as RemoveOp followed by AddOp: root removal returns MISSING, and root add recreates the document from that state.

Validation Boundaries

MISSING is not itself treated as a JSON value. The JSON helper family (JSONBoolean, JSONNumber, JSONString, JSONNull, JSONArray[T], JSONObject[T], and JSONValue) rejects it during normal Pydantic validation.

So “document missing” is a runtime state that pointer- and selector-based operations handle before type validation, not a value that happens to satisfy a JSON helper type.

JSONPointer Defaults

Root-targetable pointer operations handle document absence like this:

  • root get fails because there is no document to read
  • root remove fails when the document is already missing
  • root add recreates the document
  • root remove on an existing document returns MISSING

JSONSelector Defaults

At the root selector $, the default selector behavior mirrors the root pointer:

  • root getall fails because there is no document to read
  • root removeall fails when the document is already missing
  • root addall recreates the document

An individual operation can therefore still delete or recreate the whole document without implying that MISSING is intrinsically compatible with types such as JSONBoolean or JSONValue. Higher-level boundaries such as model revalidation or HTTP response serialization can still reject a final missing document if that contract requires a real JSON value.