π«π· 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:
- a validation schema for URL parameters;
- a validation schema for the query string;
- a validation schema for the body;
- a response schema (when the team took the time to declare it);
- existing Fastify handlers;
- legacy routers still in JavaScript;
- a web client in the middle of a migration to TypeScript;
- a mobile application that also needed to consume these contracts.
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:
- a single source of truth for the HTTP contract;
- the client must fail to compile if it doesn't respect the server contract;
- a solution compatible with an existing codebase, not just with new code.
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:
- It also requires going back over many existing routes to extract and move the types.
- It forces us to maintain a folder structure parallel to the API in another package.
Over time, this package would likely become hard to organize:
- Which types are still in use?
- Who owns them?
- How do we avoid types that are too generic, or duplicates?
β 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:
- Is there a library able to turn a JSON Schema into a TypeScript type?
Good news,
json-schema-to-typescript
can compile a JSON Schema into a TypeScript definition!
- How do we collect all the server's routes to generate a registry usable on the client side?
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:
- the server remains the single source of truth;
- we only generate TypeScript types, no runtime code;
- clients keep their existing HTTP layer, we just plug the typing into it.
This registry lets us answer questions like:
- for the
POSTmethod, does the URL/users/:userId/messagesexist? - which
pathParamsare expected? (:userId) - which body is allowed?
- what is the response format?
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:
- a route that is defined but never wired up won't be generated;
- a route added by a plugin will be collected;
- Fastify prefixes applied during
registerare taken into account; - the generator doesn't need to understand the internal organization of the code.
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:
paramsbecomespathParamson the client side;querystringbecomesquery;bodystaysbody;responsebecomes the return type.
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:
'/users/:userId/messages'is a validPOSTroute;pathParams.userIdis required;body.contentis required;message.idandmessage.contentexist in the response.
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:
- runtime generation is more faithful to the application as it actually starts;
- it depends less on how the code is organized;
- it costs more, because the server has to boot.
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:
- clients can consume the types without running the generator or having to build the server;
- contract changes are visible in code review;
- CI can detect that a PR modified routes without updating the types (we actually have a CI action to generate them automatically);
- client packages don't need to know the server's details to typecheck.
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
- Less duplication: DTOs are no longer copied into each client, the type is derived from the server schema. UI-specific presentation types remain on the client side.
- Safer refactors: when a route changes (renamed parameter, field made required, modified response, removed route...), incompatible clients break at typecheck.
- Progressive adoption: no full SDK to migrate in one go. A client keeps its HTTP layer and adopts the typed wrapper module by module, which is decisive in an existing codebase.
- Better contract review: when a PR modifies a schema, the diff of the generated types shows the impact for consumers.
Limitations
- Quality depends on the schemas: the generator invents nothing. A missing, too-broad, or
unknownschema produces a correspondingly less useful type. The approach encourages better schema maintenance, but doesn't replace that discipline. - Error responses remain difficult: our first version mostly types successes. Errors (
400,401,404,409,422...) are part of the contract but raise questions about a common format, per-route declaration, and client-side ergonomics. - Generated files add noise: one file per route is simple to generate but produces large diffs when schemas change often.
- Runtime generation has a cost: starting the server is more faithful but slower, and assumes the environment registers all routes properly (flags, optional modules, configuration).
- TypeScript doesn't validate the runtime: if the server returns data outside the schema, the client won't see it at execution time. That would require validation on the client or server side.
Improvements
Several leads have already been identified:
- Better typing of errors, to properly handle conflicts, validations, or missing resources;
- Producing a schema quality report (routes without a response, types falling back to
unknown, ignored status codes...) to turn it into a tool for improving the contract; - Reducing the generated volume with a more compact format, better for diffs and typechecking;
- Optionally generating more client tooling (URL helpers, query keys, path param replacement...) without locking clients in;
- Combining static types and runtime validation, useful where client and server can get out of sync, notably on mobile.
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.