TypeScript en pratique - 12 - Monorepo et partage de types

Partager des types entre packages dans un monorepo avec les project références, les workspaces pnpm/bun et le pattern shared/.

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 :

  1. 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.

  2. 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

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.