API contracts: what the backend owns, what the front end owns, and the schema in the middle
Data cleaning belongs on the backend. The front end shapes for presentation, not repair. Both sides validate the same contract from one schema, and the backend never omits keys or lies with optionality, because every gap becomes defensive code in every client.
Most friction between a front end and a backend is contract friction. A key that is sometimes missing. A number that arrives as a string on Tuesdays. An enum that grew a value nobody announced. A 200 OK carrying an error body. Each one turns into defensive code somewhere, and that code never gets deleted. This post is about who owns which part of the contract, and how to keep both sides honest with one schema.
Data cleaning is the backend's job#
"Clean" data means it is already in the shape and the canonical format a consumer should get: trimmed and normalised strings, coerced types, dates as ISO 8601 with a timezone, money as integer minor units or a decimal string, enums as stable codes, booleans as booleans, soft-deleted and unauthorised rows filtered out, business rules applied. The backend does this because:
- It is closest to the source of truth and has the full picture: other tables, permissions, the business rules.
- It is one place. Cleaning in each client means web, mobile and partner integrations each drift their own way.
- It is the security boundary. The client's copy of the rules is a suggestion.
- A client that has to "fix up" a response is doing work it should never have been handed, and doing it inconsistently.
If a field can be null, that is a decision the backend made, not an accident the front end inherits.
The cardinal sin: omitting keys#
The single most expensive contract habit is returning { name: "Ada" } sometimes and { name: "Ada", nickname: "Countess" } other times. Now every consumer writes data.nickname ?? fallback everywhere, the type becomes nickname?: string, and optional chaining spreads through the codebase like mould.
// BAD: shape depends on whether a relation was loaded
{ "id": "42", "name": "Ada" }
{ "id": "43", "name": "Grace", "team": { "id": "9", "name": "Compilers" } }
// GOOD: every field always present, explicit empty when there is no value
{ "id": "42", "name": "Ada", "team": null }
{ "id": "43", "name": "Grace", "team": { "id": "9", "name": "Compilers" } }- Every field in the contract is always present. If there is no value, send an explicit typed empty:
nullfor "no value",[]for "no items",""only when an empty string is meaningful. nullmeans "known to be absent". A missing key means "someone forgot". JSON has noundefined. A well-typed response never omits.- Optionality is a modelled decision, and it should be rare. A field that is optional because "the ORM did not load that relation" is a bug in the endpoint, not an optional field.
- Nullable everything is almost as bad. If
emailis typedstring | nullbut this endpoint's users always have one, the type is lying, and the front end writes a guard that never runs. Model what the endpoint actually guarantees.
Consistency across endpoints#
| Thing | The rule |
|---|---|
| Entity shape | A User is the same type everywhere, or clearly named subtypes (UserSummary vs User). Not "list returns 4 fields, detail returns 11, two names differ" |
| Casing | Pick camelCase or snake_case. Never mix. Mixed casing taxes every mapping layer |
| Dates | One format: ISO 8601 with a timezone. Never "sometimes epoch, sometimes a formatted string" |
| IDs | Strings, always, even if numeric in the database. JS number loses precision past 2^53, and you do not want to learn that in production |
| Money | Integer minor units, or a decimal string. Never a float |
| Enums | Stable string codes. The client must survive an unknown value without crashing (forward compatibility) |
| Errors | One envelope: a machine-readable code, a human message, an HTTP status that matches. Not a 200 with { success: false } |
| Pagination | One pattern (cursor or offset), one response shape, on every list endpoint |
What the front end owns#
Not data cleaning. The front end should not be trimming strings, coercing types, deduping rows, or applying a business rule the backend skipped. What it does own:
- View models. Mapping the API shape to what a component needs. This is presentation shaping, not repair: formatting a date for display, computing a derived label, grouping rows for a UI. It is the anti-corruption layer from the architecture post.
- Presentation formatting. Locale, currency display, relative time, truncation, pluralisation.
- Interaction and UI state. Loading, empty and error UI, optimistic updates, focus, transitions.
- Input validation for UX. Immediate feedback as the user types. This duplicates server validation on purpose and never replaces it.
- Honouring the states the contract allows. If
avatarUrlis nullable per the contract, render a fallback. That is not fixing data, it is holding up your end of the contract.
?? {} and limp on with half a payload.One schema, guarded on both sides#
The runtime check and the compile-time type must come from a single definition, or they drift. Hand-write a TS type and a separate Zod schema and within a month they disagree.
// contracts/user.ts (a package both apps import)
import { z } from "zod"
export const Team = z.object({
id: z.string(),
name: z.string(),
})
export const User = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(["owner", "admin", "member"]).catch("member"), // survive unknown
team: Team.nullable(), // present and null, never omitted
createdAt: z.string().datetime(),
})
export type User = z.infer<typeof User> // the type IS the schema
export const UserListResponse = z.object({
items: z.array(User),
nextCursor: z.string().nullable(),
})Backend: validate your own response
Run the schema over the response before it leaves the server, at least in CI and staging, ideally always. Catching a contract violation in your own pipeline costs nothing. The front end catching it in production costs a support ticket and an incident.
// serializer / middleware on the way out
const body = UserListResponse.parse(payload) // throws if the endpoint lies
res.json(body)Front end: parse once at the boundary
// features/users/api.ts : the only place this endpoint is decoded
import { UserListResponse } from "@acme/contracts/user"
export async function getUsers(cursor?: string) {
const res = await http.get("/users", { params: { cursor } })
const parsed = UserListResponse.safeParse(res.data)
if (!parsed.success) {
reportContractError("/users", parsed.error, res.data) // loud, with payload
throw new ApiContractError("/users")
}
return parsed.data // components downstream get a guaranteed shape
}One parse, at the data-access layer, per endpoint. Components never see an unvalidated response, so they never need a guard. No data?.user?.profile?.name ?? "Unknown" five levels deep.
res.data as UserListResponse does nothing at runtime. It tells TypeScript to stop worrying and tells you nothing about what actually arrived. If the only check is a cast, you do not have a contract, you have a hope.Where each validation lives#
| Data | Who validates | For what |
|---|---|---|
| User input in a form | Front end | UX: instant feedback, disable submit, format hints |
| That same input on arrival | Backend | Authority: types, ranges, business rules, permissions. Never trusts the client |
| Rows from the database | Backend | Clean and shape into the contract, then validate the outgoing response against the schema |
| The response, on arrival | Front end | Tripwire: does it match the shared schema. Fail loud if not |
| The parsed response | Front end | Map to a view model for rendering. Formatting, not repair |
The checklist#
- Every field always present.
nullfor absent, never omit a key. - Optionality is a modelled decision, and it is rare.
- IDs are strings. One date format (ISO 8601 with timezone). Money as integer minor units or a decimal string.
- Enums are stable codes, and the client tolerates an unknown value.
- One casing convention, one error envelope, one pagination shape.
- One schema definition is the source of the type and the runtime check.
- The backend validates its own responses in CI.
- The front end parses once at the data-access layer and fails loud, with no scattered guards.
- The backend cleans. The front end formats.
Anti-patterns#
- "The front end can just handle it" for a missing or dirty field.
- Making every field nullable so the backend never has to decide.
- Hand-maintained types living apart from the runtime schema.
- Casting the API response to a type with no runtime check.
- Deep optional chaining with fallbacks because the shape is unpredictable.
- A different shape per endpoint for the same entity.
- A
200 OKwith an error body. - Numeric IDs.
- Business logic in the client because the endpoint returns raw rows.
string | null is a guard someone else has to write and maintain forever.