Skip to content
4 min readBy Judson Dunne

Typed contracts between a mobile client and its backend

Locava's API shapes live in a versioned package that the backend and both clients import. Here is why that package exists, what it cost, and what it caught.

Locava is four codebases: a React Native client, a Fastify backend, a Next.js web surface, and a package called @locava/contracts that none of them can avoid. The contracts package holds a Zod schema for every request body, every response, and every entity shape that crosses the network. It is the single decision in that system I would defend hardest, and it is also the one that took the longest to justify to myself.

The failure it prevents

The problem with a mobile client is that you cannot deploy it. You ship it, and then it exists on phones you do not control, running against a backend you do control and will keep changing. A web app that breaks on a contract change is broken until you redeploy, which is minutes. A mobile app that breaks on a contract change is broken until Apple approves a build and the user chooses to install it, which can be never.

So the dangerous change is not the obvious one. Renaming a field is obvious; TypeScript catches it in the client repo the moment you pull. The dangerous change is the one where the types still line up and the meaning moved: a field that went from required to optional, a number that used to be metres and is now feet, an enum that grew a variant the client has no branch for.

What it looks like

A contract is a Zod schema and the type inferred from it, exported together. The schema is the artefact; the type is a consequence of it.

ts
import { z } from "zod";

export const FeedCursor = z.object({
  /** Opaque to the client. The backend owns the encoding. */
  after: z.string().min(1).optional(),
  limit: z.number().int().min(1).max(50).default(20),
});

export const PostSummary = z.object({
  id: z.string(),
  caption: z.string(),
  /** Metres. Named so the unit cannot be mistaken for the other one. */
  distanceMetres: z.number().nonnegative(),
  media: z.object({
    url: z.string().url(),
    /** Pre-extracted so a card has colour before the image decodes. */
    dominantColour: z.string().regex(/^#[0-9a-f]{6}$/),
    posterUrl: z.string().url().optional(),
  }),
});

export const FeedResponse = z.object({
  items: z.array(PostSummary),
  nextCursor: z.string().nullable(),
});

export type FeedCursor = z.infer<typeof FeedCursor>;
export type PostSummary = z.infer<typeof PostSummary>;
export type FeedResponse = z.infer<typeof FeedResponse>;
packages/contracts/src/feed.ts, trimmed

The backend parses its input with the same schema the client uses to build it, and parses its own output before sending it. That second half is the part people skip, and it is the half that earns the package: a handler that returns a shape its own contract rejects fails in development, at the boundary, with a path to the offending field, rather than three weeks later in a crash report from a phone.

ts
app.get("/feed/explore", async (req, reply) => {
  const query = FeedCursor.parse(req.query);
  const page = await feed.explore(query);

  // Parsing outbound too is what makes the contract binding on both sides.
  return reply.send(FeedResponse.parse(page));
});
The response is validated on the way out, not just on the way in.

Units in the field name

distanceMetres, not distance. This reads as fussy until the first time a client divides by 1000 twice. Locava's whole proposition is the distance on the card, so the unit is the most load-bearing fact in the payload, and the cheapest place to put it is the name. A field called distance is an invitation to guess; a field called distanceMetres is not.

The same argument applies to timestamps. Every date in the contracts package is an ISO 8601 string, never an epoch number, because the two are indistinguishable once a value is in a variable and one of them is ambiguous about units by a factor of a thousand.

What it costs

Honest accounting, because the version of this argument that only lists benefits is not useful to anyone deciding whether to copy it.

  • Every API change is now two commits in two repositories, or one commit in a monorepo plus a version bump. Small changes feel disproportionately heavy.
  • Zod validation is real work at runtime. On the hot feed path it is a few hundred microseconds per response, which is invisible next to a Firestore read but is not zero, and it is the first thing I would profile if the endpoint ever mattered that much.
  • There is a temptation to put business logic in the schema. A .refine() that encodes a product rule is a rule living in the wrong place, and it will be wrong in one client and right in the other the moment the rule changes.

What it actually caught

The change that convinced me was making a post's posterUrl optional. Video posts get a pre-generated poster frame; photo posts do not need one. Optional is correct, and the client rendered undefined into an image source and showed a broken frame on every photo post in the feed.

That is not a bug the contract prevented. It is a bug the contract located. The schema change was one line, the type error appeared in the client on the next install, and the fix was in the component that had been quietly assuming a string for months. Without the shared package the schema change would have been a backend-only edit and the first report would have come from a user.

The general version: a shared contract does not stop you making breaking changes. It makes breaking changes visible at the moment you make them, in the repository that will break. For a client you cannot redeploy, that is the entire game.

References

  • judsondunne/Locava-Backend-v2

    The Fastify service described here. `@locava/contracts` is in its dependencies, and the repository-layer boundary in front of Firestore is under src/repositories.

What this is about