How our Fastify schemas became types for our API calls

By sybers (@sybers.fr)
Published:

πŸ‡«πŸ‡· This article was originally published in French on the Indy tech blog: Comment nos schΓ©mas Fastify sont devenus des types pour nos appels API. This is my English translation.

How we turned our backend route schemas into TypeScript contracts consumable by our web and mobile clients, without generating a full SDK.

For a long time, our main client was a Vue 2 + JavaScript application. Network calls were untyped, the contract was implicit, and we often discovered mismatches at runtime when validation failed. Not ideal.

There wasn't much we could do about it as long as the client was mostly written in JavaScript. But when it came time to migrate to Vue 3 and TypeScript, one question became central:

How do we properly type our API responses to avoid runtime errors as much as possible?

Our APIs already exposed many Fastify routes with json-schema validation schemas.

These schemas were primarily used at runtime: validating params, querystring, body, and sometimes documenting responses. They already existed on a large part of the API, including on some legacy routers still written in JavaScript.

Our intuition was simple: rather than inventing a new source of truth on the client side, could we reuse these server schemas to produce TypeScript types?

The goal was not to generate a full SDK, nor to replace our HTTP client. We wanted a type-only system: erased at compile time, with no impact on bundle size.

The problem: typing an existing API

What we already had for most API routes:

The risk, when switching to TypeScript, was creating a second source of truth on the client side: hand-written types in the client, more or less in sync with the server schemas.

So we had three goals:

Options considered

Define an OpenAPI contract

The first option was to formalize an OpenAPI contract for the whole API, then generate client types from that spec through codegen.

It's a valid approach in many cases, and it works particularly well when several teams or partners consume a public API, or when documentation is the core product.

But in our case, this option is heavy. It requires modifying a lot of routes to produce a clean and consistent OpenAPI contract. It also adds an extra maintenance layer: you have to make sure the spec stays aligned with the Fastify schemas and the existing handlers.

Our need is more targeted: take advantage of the contracts that are already present in the server.

Create a shared types package

The second option was to create a types package shared between the client and the server.

It looks simple, but it has two problems:

Over time, this package would likely become hard to organize:

β†’ We want to avoid creating a new gray area between the server and the clients.

Generate types from the existing schemas

The third option was to generate types from the JSON Schemas already present on the routes.

This option fits our codebase better. The schemas already exist. They're already attached to the routes. They also cover some legacy routers in JavaScript, which matters for a progressive migration.

Two technical questions remained:

Good news, json-schema-to-typescript can compile a JSON Schema into a TypeScript definition!

This is what became the core of our implementation: a Fastify plugin that collects the API routes, extracts their schemas, generates the types, then exposes them in a format consumable by a typed client.

The chosen approach: generate typing only

The trade-off we chose is the following:

This registry lets us answer questions like:

Solution architecture

Overview of the pipeline, from the Fastify server to the typed clients:

Fastify server
  Routes + JSON Schema
    └─ onRoute hook: collects method, URL, schemas
    └─ onReady hook: triggers the generation
          β”‚
          β–Ό
  Generator (json-schema-to-typescript)
          β”‚
          β–Ό
  Generated .d.ts types
    β”œβ”€ one file per route
    └─ index.d.ts (ApiTypes)
          β”‚
          β–Ό
  Typed HTTP wrapper (api.get / api.post ...)
          β”‚
          β”œβ”€β–Ά Web client
          └─▢ Mobile application

1. Collecting the API routes

The generator is plugged in as a Fastify plugin that listens to the onRoute hook, which is called every time a new route is registered. At that point we collect the HTTP method, the URL, and the Fastify schemas. We also collect some metadata, such as the source file in which the route is defined.

In pseudo-code:

const collectedRoutes = [];

fastify.addHook("onRoute", (routeOptions) => {
  collectedRoutes.push({
    method: routeOptions.method,
    url: routeOptions.url,
    schema: routeOptions.schema,
  });
});

This approach is deliberately runtime-based (we don't do static analysis). We start the application, let Fastify register its routes, then observe the result.

This avoids several pitfalls:

2. Triggering the generation when the application is ready

Once all routes are registered, Fastify triggers the onReady hook.

That's when the generator turns the collected list into TypeScript files.

Simplified pseudo-code:

fastify.addHook("onReady", async () => {
  await generateTypes(collectedRoutes);
});

This guarantees that types are generated from the complete list of routes, not from an intermediate state of the server.

3. Converting JSON Schemas to TypeScript

For each route, the generator extracts the relevant schemas:

Then each JSON Schema is converted to TypeScript.

A route like this one:

const messagesRoutes = async (fastify) => {
  fastify.post(
    "/:userId/messages",
    {
      schema: {
        params: {
          type: "object",
          required: ["userId"],
          additionalProperties: false,
          properties: {
            userId: { type: "string" },
          },
        },
        body: {
          type: "object",
          required: ["content"],
          additionalProperties: false,
          properties: {
            content: { type: "string" },
          },
        },
        response: {
          201: {
            type: "object",
            required: ["id", "content"],
            additionalProperties: false,
            properties: {
              id: { type: "string" },
              content: { type: "string" },
            },
          },
        },
      },
    },
    async (request, reply) => {
      // route implementation...
    },
  );
};

fastify.register(messagesRoutes, { prefix: "/users" });

becomes a route type:

// users_userId_messages.POST.ts

type Route = {
  input: {
    pathParams: {
      userId: string;
    };
    body: {
      content: string;
    };
  };
  response: {
    201: {
      id: string;
      content: string;
    };
  };
};

4. Generating the global route index

Each route is generated in its own file. Then an index file aggregates all the routes by HTTP method.

Schematically:

// index.d.ts

export type ApiRoutesPost = {
  "/users/:userId/messages": import("./users_userId_messages.POST").default;
  "/projects/:projectId/invitations": import("./projects_projectId_invitations.POST").default;
};

export type ApiRoutesGet = {
  "/users/me": import("./users_me.GET").default;
  "/projects/:projectId": import("./projects_projectId.GET").default;
};

export type ApiTypes = {
  GET: ApiRoutesGet;
  POST: ApiRoutesPost;
};

In the end we get a folder with the following structure:

generated-server-types/
β”œβ”€β”€ index.d.ts
β”œβ”€β”€ users_userId_messages.POST.ts
β”œβ”€β”€ projects_projectId_invitations.POST.ts
β”œβ”€β”€ users_me.GET.ts
└── projects_projectId.GET.ts
└── ... // other routes

Consuming the types on the client side

Client applications don't import the route files one by one. They import the global ApiTypes type defined in index.d.ts, then use it to type their HTTP client.

The idea is to write a generic wrapper around the existing HTTP client. This wrapper contains a bit of runtime code, since it has to actually send the request, replace the path params, and pass along the body or the query string. However, all the knowledge about routes must stay at the type level.

Here is a partial example of the code that types the client:

// extracts a route's inputs, only if it has any
type RouteInputs<Route> = Route extends { input: infer Input } ? Input : never;

// extracts a route's response, only if it has one
type RouteResponse<Route> = Route extends { response: infer Response }
  ? Response[keyof Response]
  : unknown;

// the fetcher is scoped by HTTP method
function createFetcher<Method extends keyof ApiTypes>(method: Method) {
  return async <Path extends keyof ApiTypes[Method], Route extends ApiTypes[Method][Path]>(
    path: Path,
    ...args: RouteInputs<Route> extends never ? [] : [RouteInputs<Route>]
  ): Promise<RouteResponse<Route>> => {
    // Here we call the existing HTTP client.
    // TypeScript has already validated path, args and response.
    return httpClient.request({ method, path, args });
  };
}

Using the wrapper, we expose an easy-to-use API with one function per supported HTTP method:

export const api = {
  get: createFetcher("GET"),
  post: createFetcher("POST"),
  put: createFetcher("PUT"),
  patch: createFetcher("PATCH"),
  delete: createFetcher("DELETE"),
};

The client call then becomes:

const message = await api.post("/users/:userId/messages", {
  pathParams: { userId: "user_123" },
  body: { content: "Hello" },
});

At this point, TypeScript knows that:

If we forget pathParams or if the body is incompatible, the code won't compile.

The HTTP client stays the same. What changes is that TypeScript now knows the contract of each endpoint.

At compile time, the generated types disappear. The final JavaScript only contains the HTTP wrapper actually used by the application.

The goal was not to make the client bundle grow.

Deriving domain types from the routes

Another important benefit is the ability to derive types from the API contract.

For example:

type CreateMessageBody = ExtractBody<"POST", "/users/:userId/messages">;
type CreateMessageResponse = ExtractResponse<"POST", "/users/:userId/messages">;
type Message = CreateMessageResponse;

These helpers avoid recreating DTOs on the client side.

They are particularly useful in forms, stores, data-fetching hooks, or presentation models.

Example:

type UpdateProfileBody = ExtractBody<"PUT", "/users/me/profile">;

function buildUpdateProfileBody(form: ProfileForm): UpdateProfileBody {
  return {
    firstName: form.firstName,
    lastName: form.lastName,
  };
}

If the backend makes a field required, or renames a property, the derived type changes automatically. The client code that builds the body is then checked at typecheck time.

Why not statically parse the code?

We could have chosen to statically analyze the route files to find the calls to fastify.route.

But this approach quickly becomes fragile.

In a real application, routes aren't always declared in the same place. They can be grouped in plugins, dynamically prefixed, conditionally registered, composed per module, or wrapped in helpers.

A static analyzer would have to understand many internal conventions to reconstruct the real HTTP surface.

Fastify, on the other hand, already knows that surface. The onRoute hook is therefore a much more reliable observation point.

The trade-off is clear:

In our case, that cost is acceptable for a generation command run locally or in CI.

Integration into the development workflow

Generation is exposed through a dedicated command:

pnpm gen:client-types

This command starts the server in an environment set up for generation, collects the routes, then writes the .d.ts files into a shared package.

The generated files are committed.

This choice can be debated. Many teams prefer not to commit generated files. In our case, committing them has several advantages:

If the generated types are not up to date, CI fails or explicitly reports that the generation needs to be re-run.

What this approach changes day to day

The most visible change is in the day-to-day developer experience.

Before, writing an API call often meant knowing the URL, reading the backend, finding or recreating the body type, then hoping the response was correctly modeled.

After generation, the URL itself becomes a typed key.

// autocomplete only suggests URLs that exist for GET
await api.get("/users/me");

If the route exists, TypeScript infers the response.

const user = await api.get("/users/me");

user.email; // βœ… string, typed from the response schema
user.subscription.plan; // βœ… inferred, autocompleted
user.emial; // ❌ Property 'emial' does not exist on type ...

If the route doesn't exist for this method, TypeScript flags it.

// ❌ Argument of type '"/users/me"' is not assignable to
//    parameter of type keyof ApiRoutesPost
await api.post("/users/me");

For routes with parameters, the client is guided:

await api.get("/projects/:projectId", {
  pathParams: {
    projectId: "project_123", // βœ… required: forgetting it triggers an error
  },
});

For mutations, the body is constrained:

await api.put("/users/me/profile", {
  body: {
    firstName: "Ada",
    lastName: "Lovelace",
    // ❌ an unknown property or an incorrect type is rejected here
  },
});

This doesn't eliminate every bug, but it eliminates an entire category of client/server inconsistencies.

Benefits

Limitations

Improvements

Several leads have already been identified:

In conclusion

The right solution depends on the context of your project. If you're starting a new full-TypeScript project, tRPC or a typed RPC architecture will probably be more direct. If you expose a public API consumed by partners, OpenAPI-first will probably be a better fit. And if you want to strongly standardize all network calls, a generated SDK will make more sense.

At Indy, we were starting from a Fastify server, routes already described by JSON Schema, and several clients to connect. Generating types from what we already had was therefore the simplest solution to implement, and the one with the best impact-to-effort ratio.