Comment nos schémas Fastify sont devenus des types pour nos appels API
Comment nous avons transformé nos schémas de routes backend en contrats TypeScript consommables par nos clients web et mobile, sans générer un SDK complet.
Pendant longtemps, notre client principal était une application Vue 2 + JavaScript. Tous les appels réseau n'étaient pas typés, le contrat était implicite, et on découvrait souvent les écarts au runtime quand la validation échouait, pas idéal.
On ne pouvait pas y faire grand chose tant que le client restait majoritairement écrit en
JavaScript.
Mais au moment de migrer vers Vue 3 et TypeScript, une question est devenue centrale :
Comment typer proprement les réponses de nos APIs pour éviter au maximum les erreurs au runtime ?
Nos APIs exposaient déjà de nombreuses routes Fastify avec des schémas de validation json-schema.
Ces schémas servaient d'abord au runtime : valider les params, la querystring, le body, et
parfois documenter les réponses. Ils existaient déjà sur une grande partie de l'API, y compris sur
certains routeurs legacy encore écrits en JavaScript.
Notre intuition était donc simple : plutôt que d'inventer une nouvelle source de vérité côté client, pouvait-on réutiliser ces schémas serveur pour produire des types TypeScript ?
L'objectif n'était pas de générer un SDK complet, ni de remplacer notre client HTTP. On voulait un système type-only : effacé à la compilation et sans impact sur la taille du bundle.
Le problème : typer une API déjà existante
Ce que nous avions déjà pour la majorité des routes API :
- un schéma de validation pour les paramètres d'URL ;
- un schéma de validation pour la query string ;
- un schéma de validation pour le body ;
- un schéma de réponse (quand l'équipe a pris le temps de le déclarer) ;
- des handlers Fastify existants ;
- des routeurs legacy encore en JavaScript ;
- un client web en pleine migration vers TypeScript ;
- une application mobile qui devait aussi consommer ces contrats.
Le risque, en basculant vers TypeScript, était de créer une deuxième vérité côté client : des types écrits à la main dans le client, plus ou moins synchronisés avec les schémas serveur.
On avait donc ces trois objectifs :
- une seule source de vérité pour le contrat HTTP ;
- empêcher la compilation du client si le contrat avec le serveur n'est pas respecté ;
- une solution compatible avec une codebase déjà existante, pas seulement avec du nouveau code.
Les solutions envisagées
Définir un contrat OpenAPI
La première option envisagée était de formaliser un contrat OpenAPI pour l'ensemble de l'API, puis de générer les types clients à partir de cette spec via de la codegen.
C'est une approche valide dans beaucoup de cas et elle fonctionne particulièrement bien quand plusieurs équipes ou partenaires consomment une API publique, ou quand la documentation est le produit central.
Mais dans notre cas, cette option est lourde. Elle demande de modifier beaucoup de routes pour produire un contrat OpenAPI propre et homogène. Elle ajoute aussi une couche de maintenance supplémentaire : il faut s'assurer que la spec reste alignée avec les schémas Fastify et les handlers existants.
Notre besoin est plus ciblé : tirer parti des contrats déjà présents dans le serveur.
Créer un package de types partagé
La deuxième option envisagée était de créer un package de types partagé entre le client et le serveur.
Cette option paraît simple, mais elle a deux problèmes :
- Elle demande également de repasser sur beaucoup de routes existantes pour extraire et déplacer les types.
- Elle impose de maintenir une structure de dossiers parallèle à l'API dans un autre package.
Avec le temps, ce package risque de devenir difficile à organiser :
- Quels types sont encore utilisés ?
- Qui en est propriétaire ?
- Comment éviter les types trop génériques ou les doublons ?
→ On veut éviter de créer une nouvelle zone grise entre le serveur et les clients.
Générer les types depuis les schémas existants
La troisième option consistait à générer les types à partir des JSON Schemas déjà présents sur les routes.
Cette option colle mieux à notre codebase. Les schémas existent déjà. Ils sont déjà attachés aux routes. Ils couvrent aussi certains routeurs legacy en JavaScript, ce qui est important pour assurer une migration progressive.
Il restait encore deux questions techniques :
- Existe-t-il une librairie capable de transformer un JSON Schema en type TypeScript ?
Bonne nouvelle,
json-schema-to-typescript
permet de compiler un JSON Schema en définition TypeScript !
- Comment collecter toutes les routes du serveur pour générer un registre exploitable côté client ?
C'est ce qui allait devenir le cœur de notre implémentation : créer un plugin Fastify qui collecte les routes API, extrait leurs schémas, génère les types, puis les expose dans un format consommable par un client typé.
Le choix retenu : on génère uniquement du typage
Le compromis choisi est le suivant :
- le serveur reste la seule source de vérité ;
- on ne génère que des types TypeScript, pas de runtime ;
- les clients gardent leur couche HTTP existante, on branche juste le typage dessus.
Ce registre permet par exemple d'obtenir les informations suivantes :
- pour la méthode
POST, l'URL/users/:userId/messagesexiste-t-elle ? - quels
pathParamssont attendus ? (:userId) - quel body est autorisé ?
- quel est le format de la réponse ?
Architecture de la solution
Vue d'ensemble du pipeline, du serveur Fastify jusqu'aux clients typés :
flowchart TB
subgraph server[Serveur Fastify]
R[Routes + JSON Schema] --> H1[Hook onRoute<br/>collecte méthode, URL, schémas]
H1 --> H2[Hook onReady<br/>déclenche la génération]
end
H2 --> GEN[Générateur<br/>package `json-schema-to-typescript`]
subgraph out[Types générés .d.ts]
F1[un fichier par route] --> IDX[index.d.ts<br/>ApiTypes]
end
GEN --> F1
IDX --> W[Wrapper HTTP typé<br/>api.get / api.post ...]
subgraph clients[Clients]
W --> WEB[Client web]
W --> MOB[Application mobile]
end
classDef highlight fill:#FFE8A3,stroke:#B7791F,stroke-width:2px,color:#1A202C;
class GEN highlight;
1. Collecter les routes API
Le générateur est branché sous forme de plugin Fastify, on écoute le hook onRoute, qui est appelé
à chaque fois qu'une nouvelle route est enregistrée. À ce moment-là on collecte la méthode HTTP,
l'URL, et les schémas Fastify. On collecte aussi quelques métadonnées comme le fichier source dans
lequel la route est définie.
En pseudo-code ça donne :
const collectedRoutes = [];
fastify.addHook("onRoute", (routeOptions) => {
collectedRoutes.push({
method: routeOptions.method,
url: routeOptions.url,
schema: routeOptions.schema,
});
});
Cette approche est volontairement runtime (on ne fait pas d'analyse statique).
On démarre l'application, on laisse Fastify enregistrer ses routes puis on observe le résultat.
Cela évite plusieurs pièges :
- une route définie mais jamais branchée ne sera pas générée ;
- une route ajoutée par un plugin sera collectée ;
- les préfixes Fastify appliqués lors du
registersont pris en compte ; - le générateur n'a pas besoin de comprendre l'organisation interne du code.
2. Lancer la génération quand l'application est prête
Une fois toutes les routes enregistrées, Fastify déclenche le hook onReady.
C'est à ce moment-là que le générateur transforme la liste collectée en fichiers TypeScript.
Pseudo-code simplifié :
fastify.addHook("onReady", async () => {
await generateTypes(collectedRoutes);
});
Ce choix garantit que l'on génère les types à partir de la liste complète des routes, pas à partir d'un état intermédiaire du serveur.
3. Convertir les JSON Schemas en TypeScript
Pour chaque route, le générateur extrait les schémas utiles :
paramsdevientpathParamscôté client ;querystringdevientquery;bodyrestebody;responsedevient le type de retour.
Puis chaque JSON Schema est converti en TypeScript.
Une route comme celle-ci :
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) => {
// implémentation de la route...
},
);
};
fastify.register(messagesRoutes, { prefix: "/users" });
devient un type de route :
// users_userId_messages.POST.ts
type Route = {
input: {
pathParams: {
userId: string;
};
body: {
content: string;
};
};
response: {
201: {
id: string;
content: string;
};
};
};
4. Générer l'index global des routes
Chaque route est générée dans un fichier dédié. Ensuite, un fichier d'index agrège toutes les routes par méthode HTTP.
Schématiquement :
// 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;
};
Au final on obtient un dossier avec la structure suivante :
generated-server-types/
├── index.d.ts
├── users_userId_messages.POST.ts
├── projects_projectId_invitations.POST.ts
├── users_me.GET.ts
└── projects_projectId.GET.ts
└── ... // autres routes
Consommer les types côté client
Les applications clientes n'importent pas les fichiers de routes un par un. Elles importent le type
global ApiTypes défini dans index.d.ts, puis l'utilisent pour typer leur client HTTP.
L'idée est d'écrire un wrapper générique autour du client HTTP existant. Ce wrapper contient un peu de code runtime, puisqu'il doit réellement envoyer la requête, remplacer les path params et transmettre le body ou la query string. En revanche, toute la connaissance des routes doit rester au niveau des types.
Voici un exemple partiel du code qui permet de typer le client :
// extrait les inputs d'une route, seulement si elle existe
type RouteInputs<Route> = Route extends { input: infer Input } ? Input : never;
// extraire la réponse d'une route, seulement si elle existe
type RouteResponse<Route> = Route extends { response: infer Response }
? Response[keyof Response]
: unknown;
// le fetcher est scopé par méthode HTTP
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>> => {
// Ici, on appelle le client HTTP existant.
// TypeScript, lui, a déjà validé path, args et response.
return httpClient.request({ method, path, args });
};
}
À l'aide du wrapper, on expose une API facile d'utilisation avec une fonction par méthode HTTP supportée :
export const api = {
get: createFetcher("GET"),
post: createFetcher("POST"),
put: createFetcher("PUT"),
patch: createFetcher("PATCH"),
delete: createFetcher("DELETE"),
};
L'appel client devient alors :
const message = await api.post("/users/:userId/messages", {
pathParams: { userId: "user_123" },
body: { content: "Hello" },
});
À ce stade, TypeScript sait que :
'/users/:userId/messages'est une routePOSTvalide ;pathParams.userIdest obligatoire ;body.contentest obligatoire ;message.idetmessage.contentexistent dans la réponse.
Si on oublie pathParams ou si le body est incompatible, le code ne compilera pas.
Le client HTTP reste le même. Ce qui change, c'est que TypeScript connaît maintenant le contrat de chaque endpoint.
Au moment de la compilation, les types générés disparaissent. Le JavaScript final ne contient que le wrapper HTTP réellement utilisé par l'application.
Le but était de ne pas faire grossir le bundle du client.
Dériver des types métier depuis les routes
Un autre bénéfice important est la possibilité de dériver des types depuis le contrat API.
Par exemple :
type CreateMessageBody = ExtractBody<"POST", "/users/:userId/messages">;
type CreateMessageResponse = ExtractResponse<"POST", "/users/:userId/messages">;
type Message = CreateMessageResponse;
Ces helpers évitent de recréer des DTO côté client.
Ils sont particulièrement utiles dans les formulaires, les stores, les hooks de data fetching ou les modèles de présentation.
Exemple :
type UpdateProfileBody = ExtractBody<"PUT", "/users/me/profile">;
function buildUpdateProfileBody(form: ProfileForm): UpdateProfileBody {
return {
firstName: form.firstName,
lastName: form.lastName,
};
}
Si le backend rend un champ obligatoire, ou change le nom d'une propriété, le type dérivé change automatiquement. Le code client qui construit le body est alors vérifié au typecheck.
Pourquoi ne pas parser statiquement le code ?
On aurait pu faire le choix d'analyser statiquement les fichiers de routes pour trouver les appels à
fastify.route.
Mais cette approche devient vite fragile.
Dans une application réelle, les routes ne sont pas toujours déclarées au même endroit. Elles peuvent être regroupées dans des plugins, préfixées dynamiquement, enregistrées conditionnellement, composées par module, ou encapsulées dans des helpers.
Un analyseur statique devrait comprendre beaucoup de conventions internes pour reconstruire la surface HTTP réelle.
Fastify, lui, connaît déjà cette surface. Le hook onRoute fournit donc un point d'observation
beaucoup plus fiable.
Le compromis est clair :
- la génération runtime est plus fidèle à l'application réellement démarrée ;
- elle est moins dépendante de l'organisation du code ;
- elle coûte plus cher, parce qu'il faut booter le serveur.
Dans notre cas, ce coût est acceptable pour une commande de génération lancée en local ou en CI.
Intégration dans le workflow de développement
La génération est exposée via une commande dédiée :
pnpm gen:client-types
Cette commande démarre le serveur dans un environnement prévu pour la génération, collecte les
routes, puis écrit les fichiers .d.ts dans un package partagé.
Les fichiers générés sont commités.
Ce choix peut faire débat. Beaucoup d'équipes préfèrent ne pas commiter les fichiers générés. Dans notre cas, les commiter a plusieurs avantages :
- les clients peuvent consommer les types sans lancer le générateur ni devoir build le serveur ;
- les changements de contrat sont visibles en revue de code ;
- la CI peut détecter qu'une PR a modifié des routes sans mettre à jour les types (on a d'ailleurs une action CI pour les générer automatiquement) ;
- les packages clients n'ont pas besoin de connaître les détails du serveur pour typechecker.
Si les types générés ne sont pas à jour, la CI échoue ou signale explicitement qu'il faut relancer la génération.
Ce que cette approche change au quotidien
Le changement le plus visible est dans l'expérience de développement au quotidien.
Avant, écrire un appel API consistait souvent à connaître l'URL, lire le backend, trouver ou recréer le type du body, puis espérer que la réponse était correctement modélisée.
Après génération, l'URL devient elle-même une clé typée.
// l'autocomplétion propose uniquement les URLs existantes en GET
await api.get("/users/me");
Si la route existe, TypeScript infère la réponse.
const user = await api.get("/users/me");
user.email; // ✅ string, typé depuis le schéma de réponse
user.subscription.plan; // ✅ inféré, autocomplété
user.emial; // ❌ Property 'emial' does not exist on type ...
Si la route n'existe pas pour cette méthode, TypeScript le signale.
// ❌ Argument of type '"/users/me"' is not assignable to
// parameter of type keyof ApiRoutesPost
await api.post("/users/me");
Pour les routes avec paramètres, le client est guidé :
await api.get("/projects/:projectId", {
pathParams: {
projectId: "project_123", // ✅ requis : l'oublier déclenche une erreur
},
});
Pour les mutations, le body est contraint :
await api.put("/users/me/profile", {
body: {
firstName: "Ada",
lastName: "Lovelace",
// ❌ une propriété inconnue ou un type incorrect est rejeté ici
},
});
Cela ne supprime pas tous les bugs, mais cela élimine une catégorie entière d'incohérences entre client et serveur.
Les bénéfices
- Moins de duplication : les DTO ne sont plus recopiés dans chaque client, le type est dérivé du schéma serveur. Les types de présentation propres à l'UI restent, eux, côté client.
- Des refactorings plus sûrs : quand une route change (paramètre renommé, champ rendu obligatoire, réponse modifiée, route supprimée…), les clients incompatibles cassent au typecheck.
- Une adoption progressive : pas de SDK complet à migrer d'un bloc. Un client garde sa couche HTTP et adopte le wrapper typé module par module — décisif dans une codebase existante.
- Une meilleure revue de contrat : quand une PR modifie un schéma, le diff des types générés montre l'impact pour les consommateurs.
Les limites
- La qualité dépend des schémas : le générateur n'invente rien. Un schéma absent, trop large ou
en
unknownproduit un type d'autant moins utile. L'approche incite à mieux maintenir les schémas, sans remplacer cette discipline. - Les réponses d'erreur restent difficiles : notre première version type surtout les succès. Les
erreurs (
400,401,404,409,422…) font partie du contrat mais soulèvent des questions de format commun, de déclaration par route et d'ergonomie côté client. - Les fichiers générés ajoutent du bruit : un fichier par route est simple à générer mais produit des diffs volumineux quand les schémas bougent souvent.
- La génération runtime a un coût : démarrer le serveur est plus fidèle mais plus lent, et suppose que l'environnement enregistre bien toutes les routes (flags, modules optionnels, configuration).
- TypeScript ne valide pas le runtime : si le serveur renvoie une donnée hors schéma, le client ne le verra pas à l'exécution. Il faudrait pour cela une validation côté client ou serveur.
Améliorations
Plusieurs pistes sont déjà identifiées :
- Mieux typer les erreurs, pour gérer proprement conflits, validations ou ressources absentes ;
- Produire un rapport de qualité des schémas (routes sans réponse, types tombant en
unknown, statuts ignorés…) pour en faire un outil d'amélioration du contrat ; - Réduire le volume généré avec un format plus compact, meilleur pour les diffs et le typecheck ;
- Générer plus d'outillage client de façon optionnelle (helpers d'URL, query keys, remplacement des path params…) sans enfermer les clients ;
- Articuler types statiques et validation runtime, utile là où client et serveur peuvent se désynchroniser, notamment sur mobile.
En conclusion
La solution à mettre en place dépendra du contexte de votre projet. Si vous démarrez un nouveau projet full TypeScript, tRPC ou une architecture RPC typée sera sûrement plus directe. Si vous exposez une API publique consommée par des partenaires, OpenAPI-first sera sans doute plus adapté. Et si vous cherchez à standardiser fortement tous les appels réseau, un SDK généré aura plus de sens.
Chez Indy, nous partions d'un serveur Fastify, de routes déjà décrites par JSON Schema et de plusieurs clients à connecter. Générer les types depuis cet existant était donc la solution la plus simple à implémenter, et celle qui offrait le meilleur rapport impact/effort.