12 - Monorepo et partage de types
Ce que tu vas apprendre
- Comment structurer un monorepo TypeScript avec des workspaces
- Le pattern
shared/pour centraliser les types - Les project références dans tsconfig et pourquoi elles sont utiles
- Les barrel files (index.ts) et leur impact sur les performances
- Les types aux frontieres entre services
- Comment éviter les dépendances circulaires et le piège du re-export
Prerequisites
Avoir lu l'article sur les déclaration files.
Pourquoi un monorepo
Quand ton projet grossit, tu te retrouves avec un backend, un frontend, peut-etre une app mobile, des workers, des scripts. Tous partagent des types : les réponses d'API, les entités du domaine, les constantes.
Deux approches classiques :
Multi-repo : chaque package est un dépôt Git séparé. Les types partages sont dans un package npm publie. Chaque mise à jour nécessité une publication + un bump de version dans tous les consommateurs.
Monorepo : tout est dans un seul dépôt. Les packages se referencent directement. Un changement de type est immédiatement visible partout.
Le monorepo gagne pour le partage de types. La boucle de feedback est instantanee.
Structure de base
monorepo/
packages/
shared/ # types et utilitaires partages
src/
types.ts
constants.ts
index.ts
package.json
tsconfig.json
api/ # backend
src/
routes/
index.ts
package.json
tsconfig.json
web/ # frontend
src/
components/
App.tsx
package.json
tsconfig.json
package.json # racine
pnpm-workspace.yaml
tsconfig.base.json
Configuration des workspaces
Avec pnpm, déclaré les workspaces dans pnpm-workspace.yaml :
yaml# pnpm-workspace.yaml
packages:
- "packages/*"
Avec Bun, c'est dans le package.json racine :
json{
"workspaces": ["packages/*"]
}
Chaque package a son propre package.json avec un nom dans un scope :
json{
"name": "@monapp/shared",
"version": "0.0.0",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
Pour consommer le package shared dans l'API :
json{
"name": "@monapp/api",
"dependencies": {
"@monapp/shared": "workspace:*"
}
}
workspace:* dit au gestionnaire de packages de résoudre la dépendance localement.
Le package shared/
Le package shared/ contient les types qui traversent les frontieres entre services :
typescript// packages/shared/src/types.ts
export interface User {
id: string
email: string
name: string
role: "admin" | "user"
createdAt: string // ISO 8601 — pas Date, car JSON ne serialise pas les dates
}
export interface ApiResponse<T> {
data: T
meta: {
total: number
page: number
pageSize: number
}
}
export interface ApiError {
code: string
message: string
details?: Record<string, string[]>
}
// DTOs pour les mutations
export interface CreateUserInput {
email: string
name: string
role?: "admin" | "user"
}
export interface UpdateUserInput {
name?: string
role?: "admin" | "user"
}
Un piège courant : mettre Date au lieu de string pour les dates. Quand les donnees passent par JSON (entre le backend et le frontend), les Date deviennent des strings ISO. Le type doit refleter ce que le consommateur recoit réellement.
Barrel files et performances
Un barrel file reexporte tout depuis un index.ts :
typescript// packages/shared/src/index.ts
export * from "./types"
export * from "./constants"
export * from "./validators"
export * from "./utils"
C'est pratique pour les imports :
typescriptimport { User, ApiResponse, formatDate } from "@monapp/shared"
Mais ca a un coût. Quand un consommateur importe une seule chose, le compilateur TypeScript charge quand meme tous les fichiers reexportes. Sur un gros projet, ca ralentit la compilation.
Mon approche sur paltemps.fr :
typescript// Au lieu de tout reexporter depuis index.ts
import { User } from "@monapp/shared"
// Importer directement le fichier
import { User } from "@monapp/shared/types"
Pour ca, configure les exports dans package.json :
json{
"name": "@monapp/shared",
"exports": {
".": "./src/index.ts",
"./types": "./src/types.ts",
"./constants": "./src/constants.ts",
"./validators": "./src/validators.ts"
}
}
Ca donne le meilleur des deux mondes : imports courts pour ceux qui veulent, et imports cibles pour les performances.
Project références
Les project références TypeScript permettent la compilation incrementale entre packages :
jsonc// tsconfig.base.json (racine)
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"composite": true
}
}
jsonc// packages/shared/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"]
}
jsonc// packages/api/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" }
]
}
Avec composite: true et references, tsc --build compile les packages dans le bon ordre et utilise le cache. Si shared/ n'a pas change, il n'est pas recompile.
bashtsc --build packages/api
C'est ce qui fait la différence sur les gros monorepos. Sans project références, chaque tsc recompile tout. Avec, seuls les packages modifies sont recompiles.
Types aux frontieres d'API
Les types dans shared/ definissent le contrat entre le backend et le frontend. Pour renforcer ce contrat, combine les types avec Zod :
typescript// packages/shared/src/schemas.ts
import { z } from "zod"
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["admin", "user"]),
createdAt: z.string().datetime()
})
export type User = z.infer<typeof UserSchema>
export const CreateUserSchema = UserSchema.omit({
id: true,
createdAt: true
})
export type CreateUserInput = z.infer<typeof CreateUserSchema>
Le backend utilise le schema pour valider, le frontend utilise le type pour l'affichage. Le schema est la source de vérité pour les deux.
Éviter les dépendances circulaires
Regle : les dépendances vont dans un seul sens.
shared <-- api
shared <-- web
shared ne depende de personne. api et web dependent de shared. api et web ne dependent pas l'un de l'autre.
Si tu as besoin de types qui existent dans api depuis web, c'est un signe que ces types devraient etre dans shared.
Outil utile pour détecter les cycles :
bashnpx madge --circular --extensions ts packages/
Le piège du re-export en cascade
Un pattern que j'ai vu dans des monorepos :
typescript// packages/shared/src/index.ts
export * from "./types"
export * from "./utils"
// packages/core/src/index.ts
export * from "@monapp/shared" // re-export tout
export * from "./services"
// packages/api/src/index.ts
export * from "@monapp/core" // re-export tout encore
export * from "./routes"
Chaque package reexporte tout le package précédent. Au bout de 3 niveaux, un seul import charge des centaines de fichiers. Le compilateur TypeScript passe son temps a résoudre ces reexports.
La solution : chaque package exporte uniquement ce qu'il definit. Pas de reexport en cascade.
typescript// packages/api/src/routes/users.ts
import { User } from "@monapp/shared/types" // direct
import { UserService } from "@monapp/core/services" // direct
Monorepo avec Turborepo
Si tu utilises Turborepo pour orchestrer les builds, configure les dépendances entre tasks :
json{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^build"]
}
}
}
"^build" signifie "build d'abord les dépendances". Turborepo comprend le graphe de dépendances et build dans le bon ordre avec du caching.
Quand ne pas utiliser de monorepo
Le monorepo n'est pas toujours la réponse. Si tes packages sont independants et maintenus par des équipes différentes avec des cycles de release différents, le multi-repo avec des packages npm publies est plus adapte. Les types partages passent alors par les déclaration files classiques.
Résumé
- Un monorepo simplifie le partage de types entre packages grace aux workspaces
- Le package
shared/centralise les types qui traversent les frontieres entre services - Les barrel files (
index.ts) sont pratiques mais coutent en performance de compilation - Les project références (
composite+references) permettent la compilation incrementale - Les dépendances vont dans un seul sens : shared <- api, shared <- web, pas de cycles
- Evite les reexports en cascade : importe directement depuis le package source
Article précédent : 11 - Déclaration files
Article suivant : 13 - Migration JS vers TS progressive
Sources
- TypeScript Project Références par Microsoft
- pnpm Workspaces par pnpm
- Turborepo Handbook par Vercel