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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
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.
| State | Wire form | On create | On partial update | Contract must say |
|---|---|---|---|---|
| Required, present | "email": "a@b.c" | Validated, stored | Validated, replaces | Type, format, constraints |
| Required, absent | (no key) | 422 with a field error | Leave unchanged (PATCH) or 422 (PUT) | Which operations require it |
| Optional, absent | (no key) | Default applied, or stored as unset | Leave unchanged | The default — and whether it is applied server-side |
| Optional, explicit null | "nickname": null | Stored as unset (or rejected if not nullable) | Clear the value | Whether 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.
1PATCH /users/422{ "nickname": null, "avatar_url": "https://…/new.png", "tiemzone": "Europe/Warsaw" }3→ 200 OK4{ "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.1PATCH /users/422{ "nickname": null, "avatar_url": "https://…/new.png", "tiemzone": "Europe/Warsaw" }3→ 422 Unprocessable Content4{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:12→ 200 OK13{ "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
nullas "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.
- 1Team → schema: types every field as "string, optional" because the ORM model has nullable columns; no field says what absence means.
- 2Server → deserializer: maps both missing and null to the runtime's nil; PATCH with
"nickname": nullbecomes a no-op. - 3Client → API: sends a misspelled field; the server ignores unknown keys; the client ships believing the feature works.
- 4Product → default: changes the server-side default of
visibilityfrom private to team; every client that omitted it now creates visible projects. - 5Support → team: "I cleared my nickname and it came back" and "my project is public" arrive as two unrelated tickets with one root cause.
- 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.
- • 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]]).
- • 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.
- • 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]]).
- • 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.