Requestsrequestsschemavalidationnulldefaultsenums

Request Contracts: Required, Optional, Null and Absent

A request schema is a set of promises about what the server will accept and what each field means. Required vs optional vs nullable, "not sent" vs "sent as null", enums, defaults and the unknown-field policy decide whether the contract can grow — or whether every addition breaks someone.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
For every field a client can send, what does the contract say about absence, null, defaults and values it has never seen — and does the server behave the same way in every operation?
Consumers
Every client that constructs a request body: a form submit that omits blank fields, a PATCH that wants to clear a value, an SDK that serializes an object with every property present, and a partner integration written against last year's docs that will keep sending last year's fields.
The promise
A well-specified request contract states, per field, whether it is required, whether it may be null, what absence means, what the default is, which values an enum accepts, and what happens to fields the server does not recognize — and applies those rules identically across every operation.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Four states, not two

Most schemas think in two states: the field is there or it is not. Real requests have four: the field is required and present, optional and absent, optional and present with a value, or present with an explicit null. A contract that collapses the last two — treating {"nickname": null} the same as omitting nickname — has no way to express "clear this value" on a partial update, and a contract that collapses the first two treats every missing optional field as a validation error.

The distinction bites hardest on PATCH. A client that reads a profile, edits one field and sends only that field expects untouched fields to stay untouched (see PUT vs PATCH for the merge semantics). A client that wants to *remove* the nickname must be able to say so, and the only honest spelling is an explicit null. If the server's deserializer maps both absent and null to the language's nil, the "clear" intent silently becomes "leave alone", and the user's nickname refuses to go away — a bug that reproduces only in the one code path that used null on purpose.

Enums are the second place where two-state thinking fails. A field typed as "priority": "low" | "normal" | "high" promises a closed set today; the contract must also say what the server does with "urgent" tomorrow — reject with a field error, coerce to a default, or accept and store. Whichever it is, the same policy needs to hold for the *response* side, where Enum Evolution: The New Value That Broke Old Clients shows how old clients break when a set they believed closed turns out to be open.

What each field state means, and the question it forces the contract to answer
StateWire formOn createOn partial updateContract must say
Required, present"email": "a@b.c"Validated, storedValidated, replacesType, format, constraints
Required, absent(no key)422 with a field errorLeave unchanged (PATCH) or 422 (PUT)Which operations require it
Optional, absent(no key)Default applied, or stored as unsetLeave unchangedThe default — and whether it is applied server-side
Optional, explicit null"nickname": nullStored as unset (or rejected if not nullable)Clear the valueWhether the field is nullable at all

Defaults, constraints and the unknown-field policy

A default is a contract clause, not an implementation detail. If POST /projects treats a missing visibility as "private", that fact belongs in the schema: clients that omit it are relying on it, and changing it later to "team" silently republishes every project created by a client that never sent the field. Defaults applied server-side should also be *echoed* in the response so the client learns what it got; a default that only exists in code is an invisible dependency (Backward Compatibility: The Real Rules treats a changed default as a meaning change — the breaking kind).

Constraints (length, range, pattern, uniqueness, cross-field rules like ends_at > starts_at) are where the Validation Errors: Feedback, Not Verdicts contract is born: every constraint the schema declares is a field error the server can return with a stable code. Declaring them also decides what the server does *before* it validates — parse the body against the schema, then validate, then authorize, then process, in that order; the security domain's Parse, Validate, Authorize, Process lesson explains why the order is a trust-boundary decision, not a style preference.

The most consequential clause is the one most schemas leave out: what happens to a field the server does not recognize. Reject (422 UNKNOWN_FIELD) catches typos and stops silent data loss — a client sending "priorty" finds out immediately — but makes every field addition a coordinated release, because old servers reject the new field from new clients during a rollout. Ignore lets clients and servers deploy independently and keeps rollbacks safe, but a misspelled field is accepted and dropped without a word. Neither is free; what is unacceptable is not choosing, so that half the endpoints reject and half ignore.

Absence, null and unknown fields all collapse into "nothing happened"
1PATCH /users/42
2{ "nickname": null, "avatar_url": "https://…/new.png", "tiemzone": "Europe/Warsaw" }
3200 OK
4{ "id": 42, "nickname": "danny", "avatar_url": "https://…/new.png", "timezone": "UTC" }
5
6# nickname still "danny": null was deserialized as "not sent".
7# "tiemzone" was silently discarded; the typo lives on in the client for months.
8# timezone still "UTC": the client believes it changed it.
Each state means one thing, and the contract says which
1PATCH /users/42
2{ "nickname": null, "avatar_url": "https://…/new.png", "tiemzone": "Europe/Warsaw" }
3422 Unprocessable Content
4{
5 "code": "VALIDATION_FAILED",
6 "errors": [
7 { "field": "tiemzone", "code": "UNKNOWN_FIELD", "hint": "did you mean timezone?" }
8 ]
9}
10
11# Retry without the typo:
12200 OK
13{ "id": 42, "nickname": null, "avatar_url": "https://…/new.png", "timezone": "Europe/Warsaw" }
14# Schema: nickname — string | null, nullable: true; absent on PATCH = unchanged.

The second contract can be learned from one failed request. The first can only be learned from a bug report, and the bug report will describe a symptom ("my nickname will not clear") three layers away from the cause.

Nesting, arrays and the shape of growth

Nested objects and arrays inherit every question above and add two. For arrays: does [] mean "set to empty" or "leave alone" on PATCH, and is there a maximum length (see Large Requests and Documented Limits — an unbounded array in a request schema is a memory limit somebody else sets)? For nested objects: is the merge deep or shallow? {"address": {"city": "Kraków"}} either updates one field of the address or replaces the whole address with a one-field object, and both readings are defensible — which is exactly why the contract must pick one and say so.

Growth is the quiet argument for optional-first design. A field added as optional with a sensible default is additive: old clients keep sending the old shape and get the old behavior. A field added as required breaks every existing client at once. The corollary is that "required" should be reserved for fields without which the operation has no meaning — and that making an optional field required later is on the breaking list, no matter how reasonable it feels.

The wire format is the last place this shows up. Explicit type per field (a string "42" is not the number 42; a timestamp is an RFC 3339 string, never an epoch integer in one endpoint and a string in another — One Vocabulary: Naming and Consistency) stops SDK generators from guessing. Generated clients faithfully reproduce ambiguities as bugs in every language at once, which is why Schema-First vs Code-First matters more for the request side than the response side: the request schema is where the server *rejects*, and rejections are what consumers experience as the API.

  • Required only for fields the operation cannot proceed without; everything else optional with a documented default.
  • Nullable is a separate flag from optional; state it per field and honor null as "clear".
  • Absent on PATCH means unchanged; the schema says whether nested merges are deep or shallow.
  • Unknown fields: one policy for the whole API (reject or ignore), written down and enforced by shared middleware.
  • Arrays carry a maximum length; enums state whether the set is closed and what an unknown value returns.

Key points

  • A request field has four states — required-present, optional-absent, optional-with-value, explicit-null — and a contract that collapses any two of them cannot express a client intent it will eventually need.
  • Explicit null is the only honest way to say "clear this" on a partial update; absent must mean "unchanged" or PATCH is not PATCH.
  • Defaults are contract clauses: document them, echo them in the response, and treat changing one as a breaking change.
  • Pick one unknown-field policy — reject catches typos, ignore enables independent deploys — and apply it everywhere.
  • Optional-first fields are additive; required-later is breaking. Reserve "required" for what the operation has no meaning without.

Progressive depth

Overview

Every field a client can send needs an answer to four questions: is it required, can it be null, what does leaving it out mean, and what is the default? Write those answers in the schema, not in the handler, and give the same answers on every endpoint.

Practical

Distinguish absent from null in the deserializer; treat absent-on-PATCH as unchanged and null as clear. Choose reject-or-ignore for unknown fields once, enforce it in middleware, and return field-level errors with stable codes (REQUIRED, NOT_NULLABLE, UNKNOWN_FIELD) so clients can branch instead of string-match — see Validation Errors: Feedback, Not Verdicts.

Advanced

Defaults and constraints are contract clauses with evolution consequences: a changed default is a meaning change, a tightened constraint breaks senders in the tail of the distribution, and optional-to-required needs a version boundary. Deep-vs-shallow merge for nested objects and set-vs-unchanged for arrays must be stated per schema, because both readings are reasonable.

Internals

The four-state distinction survives only if the parsed representation carries it: a JSON parser that yields a map can tell key-missing from key-null, but a typed deserializer that fills a struct with language nil loses the bit before validation runs. Generated SDKs inherit whatever the schema says — an ambiguous nullable becomes a language-specific guess in every client at once, which is the strongest practical argument for schema-first request design.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → schema: types every field as "string, optional" because the ORM model has nullable columns; no field says what absence means.
  2. 2
    Server → deserializer: maps both missing and null to the runtime's nil; PATCH with "nickname": null becomes a no-op.
  3. 3
    Client → API: sends a misspelled field; the server ignores unknown keys; the client ships believing the feature works.
  4. 4
    Product → default: changes the server-side default of visibility from private to team; every client that omitted it now creates visible projects.
  5. 5
    Support → team: "I cleared my nickname and it came back" and "my project is public" arrive as two unrelated tickets with one root cause.
What breaks
  • Silent data loss: intent expressed as null or as a misspelled field is discarded without an error, and only the user notices.
  • Independent deploys become impossible when some endpoints reject unknown fields — a client feature flag cannot be turned on until every server instance has rolled.
  • A changed default rewrites the meaning of every historical request that omitted the field, which no changelog line can undo.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Specify per field: required, nullable, default, constraints, and the PATCH semantics of absent vs null — in the schema, not in prose.
  • • Deserialize into a representation that preserves the absent/null distinction (an "undefined vs null" or "Option<Option<T>>" shape) before any business logic runs.
  • • Adopt one unknown-field policy for the API, enforce it in shared request middleware, and document its evolution consequences.
  • • Echo applied defaults in the create response so clients never depend on a value they cannot see.
  • • Cap every array and string length in the schema; unbounded inputs are limits someone else sets (see [[large-requests]]).
Observe in production
  • • Validation-error rates per field and code (`UNKNOWN_FIELD`, `REQUIRED`, `NOT_NULLABLE`) reveal which clients misunderstand the contract, and which docs are wrong.
  • • A spike in `UNKNOWN_FIELD` immediately after a client release is a client bug caught in minutes instead of months.
  • • Rows where a nullable column never becomes null again after the first write is the fingerprint of a null-collapsing deserializer.
Evolve without breaking
  • • New optional fields with defaults are additive under an "ignore unknown" policy; under "reject", roll servers out before clients and say so in the release notes.
  • • Tightening a constraint (shorter max length, narrower pattern) breaks clients that already send the wider range; measure existing values before tightening.
  • • Promoting optional to required requires a version boundary or a long deprecation of the omitted form, backed by per-consumer telemetry (see [[consumer-driven-evolution]]).
What it costs
  • • Preserving absent-vs-null all the way through the stack costs a richer internal representation than most language defaults provide.
  • • Rejecting unknown fields trades rollout independence for typo detection; ignoring them trades the reverse. Both are documented costs, not bugs.
  • • Per-field specification is more schema to maintain and review — cheap next to one silently discarded intent in production.

Misconceptions

Claim
“Null and absent are the same thing; the client just sends what it has.”
Reality
On a partial update they are opposite intents — "clear this" versus "leave this alone". A contract that cannot tell them apart forces clients to send full objects (PUT semantics) to clear a single field, or makes clearing impossible.
Claim
“Ignoring unknown fields is sloppy; strict servers are better.”
Reality
Strict servers make every field addition a lockstep deploy between clients and every server instance. Ignore is a legitimate choice for independent rollouts; the failure is having no policy, so behavior depends on which handler you hit.
Claim
“Defaults are implementation details clients do not need to know.”
Reality
A client that omits a field is depending on the default whether it knows it or not. Changing it changes the meaning of every request that ever omitted the field — that is a breaking change wearing a code-change costume.

Apply it