Software Architecture · API Design
Tolerant Readers, Unknown Fields, and the Cost of Strict Decoding
How clients should handle members and enum values they do not recognise, why strict decoding turns every addition into a breaking change, and where tolerance turns into silent corruption.
A server adds a field called tax_id to an invoice object. The change is additive: nothing removed, nothing retyped, no existing caller reads the new member. Clients that decode responses into a fixed type which rejects unknown members fail the moment the payload contains a key the type does not declare: their deserialiser raises an error instead of ignoring it.
Nothing in the wire format was violated. The provider shipped what schema tooling would call a compatible change and still broke consumers, because compatibility is a joint property of the payload and the reader. A reader that rejects unknown members converts every future addition into a breaking change, whether or not the producer intends it.
The principle and its amendment
The robustness principle, stated in RFC 1122, is usually quoted as: be liberal in what is accepted, be conservative in what is sent. It is the standard justification for tolerant parsing, and it has a well-known failure mode. A liberal reader accepts malformed input, the producer never learns that its output is malformed, and the malformed form eventually becomes the only form anyone tests. The analysis in RFC 9413 makes this argument directly: tolerance in the receiver removes the pressure that would otherwise force correctness in the sender.
For API evolution the useful version is narrower than the general principle. A reader should be liberal about additional information and strict about missing or contradictory information. An unknown member is an addition the reader does not understand; ignoring it is safe. A missing required field, a null where an object was promised, or a string where a number was promised are contradictions, and quietly substituting a default hides a genuine incompatibility.
That distinction gives a workable rule: ignore what is extra, fail on what is wrong. The reader’s schema should describe the subset it understands, and its validation should be enforced against that subset rather than against the whole payload.
What a tolerant reader does with an unknown member
The mechanics differ by language and format, but three properties are worth guaranteeing.
Unknown members are preserved on read. Ignoring a field is enough for a consumer that only reads, but not for one that modifies and sends back the whole object. A client that fetches an invoice, changes one field, and PUTs the full representation will silently delete every member it did not understand, which includes members added by the provider after the client was built. The failure is destructive: the client appears to work correctly and the data loss is discovered later.
Presence is distinguishable from a default. A decoded integer that defaults to zero cannot distinguish “the server sent 0” from “the server said nothing”. This matters most for patch semantics, where absent means “leave unchanged” and an explicit value means “set to this”. The representation has to keep the distinction, either as an optional type or as a presence flag.
Null and absent are different. RFC 8259 gives null a literal meaning, and JSON Merge Patch (RFC 7396) uses it to mean deletion. A decoder that collapses both cases into “no value” cannot participate in merge patching at all.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": { "type": "string" },
"total_cents": { "type": "integer" },
"status": { "type": "string" }
},
"required": ["id", "total_cents"],
"additionalProperties": true
}
The additionalProperties: true is the whole difference between a reader that survives additions and one that does not. Setting it to false is a declaration that the object’s member set is closed, which is only correct for payloads the reader owns end to end.
Enums are the hard case
An open string field is trivially tolerant. A closed enumeration is not, and the choice between the two shapes has lasting consequences.
Suppose a status field is documented with values pending, paid, and refunded. A provider that adds partially_refunded has made an additive change by any schema measure. Clients written before the change see a value outside their declared set. A deserialiser for a closed enum type will either throw, or map the value to an ordinal zero, or fall through a switch into an undefined branch.
Three reader-side strategies exist, and they cost different amounts.
Map to an unknown member. In this strategy the type includes a distinct UNKNOWN variant, all unrecognised values map to it, and every consumer must handle it. It is the approach that lets a client keep processing without storing the original text, and it requires the schema author to ask for it explicitly.
Preserve the raw string. The type stores both the parsed value and the original text, so unrecognised values round-trip intact even if the client cannot act on them. This combines well with the preservation requirement above.
Fail closed. The reader refuses to process the object. This is defensible for a field the client must branch on, such as a payment method that determines a code path, and indefensible for a decorative field such as a label category.
The matching producer-side rule is to treat the enumeration as open in the documentation, and to say what a client should do with a value it does not recognise. A field documented as closed becomes permanently closed in practice, because adding to it will break somebody.
Discriminators, unions, and validator defaults
Schema languages that support unions make tolerance harder. A discriminator pattern, where a type member selects among several object shapes, requires the reader to look up the discriminator first. An unrecognised discriminator has no shape to decode against, so the only tolerant behaviours are to ignore the whole object or to retain it opaquely.
Validators add a second layer of trouble. additionalProperties: false is a constraint on the instance itself: a validator that enforces it rejects any member the schema does not declare, so the next addition fails validation. Schema composition adds another trap: oneOf fails when a payload matches more than one branch, which makes it a poor fit for a schema that must accept additions, while anyOf succeeds when any branch matches. A provider that publishes a schema with additionalProperties: false has published a schema that will reject any later release that adds a member, and consumers that validate against it will validate their way into an outage.
The practical resolution is for the provider to publish a schema that admits unknown members, and for consumers to validate against the subset they require. Two schemas, one document: provider schema describes what may be sent, consumer schema describes what must be understood.
Tolerance on the write path
Being liberal about what is accepted has a different character on the request side. A server that ignores unknown members in a request body can hide a client bug indefinitely: the client sends ammount, the server accepts the request, and the intended value silently stays at its default. For writes, strict rejection of unknown members is often the better contract, with an error that names the unrecognised members.
The asymmetry is justified because the roles differ. In a response, the server is the producer and controls additions; a tolerant client is protecting itself against a legitimate future. In a request, the client is the producer, and a name it invented is a mistake, not a future feature. Where a request object must tolerate extra members, as with a metadata map or an extension point, the schema should say so explicitly by declaring that member open.
Where tolerance becomes corruption
Tolerance that discards information is worse than strictness. A reader that drops unknown members and then writes the object back has quietly deleted data. A reader that maps an unrecognised enum to a default and then acts on that default has silently changed business behaviour, and the resulting record looks legitimate. A reader that accepts a string where an integer was documented and coerces it will keep working until the day the provider stops tolerating the coercion.
The rule that covers all three: tolerate only what can be preserved or safely ignored, and fail on everything else. “Safely ignored” means the field has no bearing on any decision the client makes. That is a property of the field, not a property of the parser, and it belongs in the contract rather than in the decoding code. Where a change to a field’s meaning is unavoidable, the same reasoning that governs a removal applies, and deprecation windows and removal gates describes the sequence.
None of this removes the provider’s obligation to be careful about what it sends. Tolerant readers make additions survivable; they do not make an incompatible change compatible. The distinction between the two is exactly the subject of what a version number promises.