La Élume Beauté · Chapter 7 of 7

Living with a generated client

Jovanus Hartono2026

On this page

Almost every call the store makes to its backend goes through code generated from the backend's OpenAPI spec: a file that describes every endpoint of an API and the shape of what each one returns.. Orval reads the spec and writes out a TanStack Query hook for every endpoint and a type for every model, and a second pass writes Zod schemas that the forms build their validation on. A screen never writes a request by hand:

// Orval writes these from one endpoint in the spec
export interface Product {
  id: string;
  name: string;
  price: number;
}
export const useGetProducts = (params?: GetProductsParams) => useQuery(/* … */);

// A screen just calls the hook
const { data } = useGetProducts({ page: 1 });

Most of the time that's a great deal. When an endpoint changes, I regenerate, and TypeScript points at every screen that breaks. The catch is that the spec kept changing, sometimes in ways that had nothing to do with what the store did. Most of the work in this chapter went into making regeneration boring.

Read-only, on purpose

The first rule is that nobody edits generated code, and I wrote it down as a rule for anyone working on the store: if the spec is wrong or out of date, "regenerate it instead of hand-editing generated output." A fix typed into a generated file is gone the next time anyone regenerates.

That means anything the generator can't know has to live outside it. The main place for that is a single hand-written fetch function, which every generated hook calls. Orval calls it a custom client, and the config points at it:

export default defineConfig({
  store: {
    input: { target: "<the backend's spec>" },
    output: {
      client: "react-query",
      override: {
        mutator: { path: "./src/api/fetch.ts", name: "apiFetch" },
      },
    },
  },
});

The fetch function handles what every request needs:

  • It adds the token and the shopper's language to each request.
  • It sets the JSON content type, and copes with empty responses.
  • If the backend ever answers with a 401, it clears the token on the spot.

So when the backend turned out to want array parameters written as tags[]= instead of repeated tags=, the fix went there, once:

?tags=serum&tags=toner        before
?tags[]=serum&tags[]=toner    after

The login page that went blank

The fetch function throws on any response that isn't a success. That's the right choice for TanStack Query, which expects a failed request to throw, and it caught me out once.

A Code that runs before a page opens and decides whether you're allowed in, or sends you somewhere else. checked whether the profile request came back with a 401, and if so treated the visitor as signed out. It never saw the 401, because a failed request throws instead of coming back:

const response = await queryClient.fetchQuery(profileQuery);
if (response.status === 401) {
  // never runs: on a 401, fetchQuery has already thrown
}

So anyone whose token had expired got a blank page where the login screen should be.

The fix was a small shared helper for route guards. It catches what the fetch function throws, lets the router's own redirects through, turns a 401 into "your session was cleared", and re-throws anything else:

const checkSession = async (queryClient: QueryClient) => {
  try {
    await queryClient.fetchQuery(profileQuery);
    return "signed-in";
  } catch (error) {
    if (isRedirect(error)) {
      throw error; // the router's own redirect, let it through
    }
    if (statusOf(error) === 401) {
      clearSession();
      return "session-cleared";
    }
    throw error;
  }
};

The app also got an error screen at the root, so a failure can never leave a blank page again.

Every regeneration, something moved

The spec changed shape again and again, and each regeneration taught me something.

One regeneration removed the generated module for signing in and moved the "who am I" endpoint somewhere else. The store had a hand-written copy of that call alongside the generated one, and the two had drifted apart. I wrote the lesson down as a rule: use only the generated code for endpoints that are in the spec, and "avoid a second ad hoc client for the same URL."

The cart had the same problem in a different form. Reading the cart went through generated functions, but writing to it still used hand-written calls, and one response was being read as if it had a single shape when the spec said it could have several. Results now get Narrowing: checking a value so TypeScript knows which of its possible shapes it has. on their status before anything reads inside them:

const response = await getCart(); // one of several shapes, by status
if (response.status !== 200) {
  throw new Error("Couldn't load the cart");
}
response.data.items; // TypeScript now knows this is the 200 shape

One change fixed a bug that didn't exist. I switched off Orval's Files that only gather up everything in a folder and pass it on, so one import can reach all of it., which had been generating hundreds of lines that only passed other files along. Nothing in the store imported the main one. I also added a script that ran after every regeneration, to work around what I thought was a bug in the imports Orval wrote for one group of endpoints. Regenerating without the script showed the imports were fine, so the script went.

I also made the generator wipe its output folder before writing, with Orval's clean option, because every regeneration had been leaving old model files behind. The first clean run removed 306 of them. The Zod schemas had to move to a folder of their own at the same time, since wiping a shared folder would have deleted them too.

The names that kept changing

The type names changed too. The backend's model names flipped between long names built from the backend's package path, like the one in the import below, and short ones, four times. One of those renames touched 45 files in the store, and all it changed was names.

The fix was to stop the names from spreading. The store imports a generated type under a local name:

import type { StoreBackendInternalModulesCatalogDomainProduct as Product } from "./generated/model";

The long name only appears on the import line, and the rest of the file uses the short one. The next rename only touches import lines.

Componentssee view models, mostlyNormalisersgive loose data a shapeImport aliasestype renames stop hereGenerated clientregenerated, never editedOne fetch functiontoken, language, 401, tags[]Backendthe spec keeps changing
The layers between a component and the backend. Each one soaks up a different kind of change, so a new version of the spec mostly stops before it reaches a screen.

Unwrapping and normalising

Every response comes back wrapped twice: once by the fetch function, with the status and headers, and once by the backend's own envelope around the actual data:

{
  status: 200,
  headers: Headers,
  data: {
    code: 200,
    data: [/* the products you actually asked for */],
  },
}

One helper builds a TanStack Query select that unwraps both, throws on a failed status so errors flow through the query like any other, and can reshape the data in the same step:

const selectData =
  <T, R>(reshape: (data: T) => R) =>
  (response: ApiResponse<T>) => {
    if (response.status < 200 || response.status >= 300) {
      throw new Error(`Request failed with ${response.status}`);
    }
    return reshape(response.data.data);
  };

useGetProducts({ page: 1 }, { query: { select: selectData(toProductCards) } });

The reshaping happens in small A function that turns data from outside into exactly the shape the app expects. functions, one per feature, like toProductCards above, which turn generated models into exactly the shapes the screens use. Most components never see a generated type, so when the backend changes a model, the normaliser changes and the screen doesn't. Some components do still import generated types directly.

The price that was a string

The generated types are genuinely useful, but they only exist at compile time. Once the code runs, nothing checks that a value really has the type TypeScript was told it has.

I learned that when loading courier options started failing. Enter a postal code, and the shipping rates request came back with a 500.

A cart saved in localStorage held prices as strings. TypeScript was sure price was a number, because the cart's own type said so, but nothing checked the saved data when it loaded back in. JSON.stringify happily sent "150000" instead of 150000, and the backend couldn't decode it.

The first fix converted the values to numbers right before the shipping rates request. A second one cleans the saved cart as it loads, turning string prices into numbers and quantities below one into one:

// What was saved
{ "id": "serum", "price": "150000", "quantity": 0 }

// Cleaned as it loads
const cleanLine = (line: SavedLine) => ({
  ...line,
  price: Number(line.price), // "150000" becomes 150000
  quantity: Math.max(1, Number(line.quantity) || 1),
});

That's the same job the normalisers do for API responses: data from outside the app, whether it comes from the backend or from localStorage, gets checked on the way in.