TypeScript en pratique - 04 - Organiser ses types dans un projet

Colocation ou centralisation, conventions de nommage, barrel files, et comment structurer les types entre frontend et backend.

04 - Organiser ses types dans un projet

Ce que tu vas apprendre

  • Colocation vs centralisation : ou mettre les types
  • Barrel files (index.ts) : avantages et pièges
  • Conventions de nommage : IUser, UserType, Props suffix
  • Partager des types entre frontend et backend
  • Quand utiliser interface vs type en pratique

Prerequisites

Avoir lu l'article sur les variables d'environnement type-safe.


Le reflexe du dossier types/

La plupart des devs qui debutent en TypeScript creent un dossier types/ a la racine du projet et y mettent tout :

src/
  types/
    user.ts
    product.ts
    order.ts
    api.ts
    utils.ts
  components/
  pages/
  api/

Ca fonctionne sur un petit projet. Sur un projet de 50 fichiers, ca devient un problème. Les types dans types/user.ts sont utilises par components/UserCard.tsx, api/users.ts, pages/profile.tsx. Quand tu modifies UserCard, tu dois aller modifier un fichier dans un dossier complètement séparé.

La colocation

Le principe de colocation : le type vit a cote du code qui l'utilise.

src/
  features/
    users/
      types.ts          # types specifiques aux users
      UserCard.tsx
      UserList.tsx
      api.ts
    products/
      types.ts          # types specifiques aux products
      ProductCard.tsx
      api.ts
  shared/
    types/
      api.ts            # types partages (pagination, erreurs)
      database.ts       # types du schema DB

Quand tu travailles sur la feature users, tout est dans le meme dossier. Les types partages entre features vont dans shared/types/.

La regle : un type utilise par un seul module vit dans ce module. Un type utilise par plusieurs modules monte dans le parent commun ou dans shared/.

Barrel files : le piège classique

Un barrel file reexporte tout depuis un index.ts :

typescript// features/users/index.ts
export { UserCard } from "./UserCard"
export { UserList } from "./UserList"
export type { User, CreateUserInput } from "./types"
export { fetchUsers, createUser } from "./api"

L'avantage : des imports propres.

typescriptimport { UserCard, type User } from "@/features/users"

Les problèmes :

  1. Circular imports. Si UserCard importe depuis ./types et index.ts reexporte les deux, certains bundlers creent des cycles.

  2. Tree-shaking casse. Si tu importes User depuis le barrel, le bundler peut inclure UserCard, UserList et api dans le bundle. Webpack et Rollup ont des heuristiques pour éviter ca, mais elles ne fonctionnent pas toujours.

  3. Performance IDE. TypeScript doit résoudre tout le barrel pour l'autocompletion. Sur un projet avec 20 barrel files qui reexportent des centaines de symboles, l'IDE rame.

Mon avis : les barrel files sont utiles pour les packages publics (une lib npm). Pour du code interne, importe directement depuis le fichier source.

typescript// Prefere ca
import type { User } from "@/features/users/types"
import { UserCard } from "@/features/users/UserCard"

Conventions de nommage

Le prefixe I

typescript// ❌ Convention C#/Java — pas idiomatique en TypeScript
interface IUser {
  id: string
  name: string
}

// ✅ Convention TypeScript
interface User {
  id: string
  name: string
}

Le prefixe I vient de C#. L'équipe TypeScript elle-meme ne l'utilise pas. Dans le code source de TypeScript, les interfaces s'appellent Node, Type, Symbol — pas INode.

Le suffixe Props

Pour les composants React, le suffixe Props est la convention standard :

typescriptinterface UserCardProps {
  user: User
  onEdit: (id: string) => void
  className?: string
}

function UserCard({ user, onEdit, className }: UserCardProps) {
  // ...
}

C'est un des rares cas ou un suffixe est universellement accepte.

Autres conventions

typescript// Types de reponse API
type UserResponse = { user: User }
type UsersResponse = { users: User[]; total: number }

// Types d'input
type CreateUserInput = { name: string; email: string }
type UpdateUserInput = Partial<CreateUserInput>

// Types d'erreur
type ApiError = { code: string; message: string }

Les suffixes Response, Input, Error sont clairs et descriptifs. Pas besoin d'inventer un système complexe.

interface vs type en pratique

La question revient tout le temps. Voici ma regle :

interface pour les objets avec une forme fixe (modèles, props, contrats d'API) :

typescriptinterface User {
  id: string
  name: string
  email: string
  createdAt: Date
}

interface UserRepository {
  findById(id: string): Promise<User | null>
  create(data: CreateUserInput): Promise<User>
}

type pour tout le reste (unions, intersections, tuples, types derives) :

typescripttype Role = "admin" | "user" | "editor"
type UserWithRole = User & { role: Role }
type Maybe<T> = T | null | undefined
type AsyncResult<T> = Promise<Result<T, Error>>

En pratique, la différence technique est minime. Les deux supportent l'extension. Les deux sont verifies structurellement. La seule différence notable : interface supporte la déclaration merging (deux déclarations interface User fusionnent), type non.

Sur paltemps.fr, j'utilise interface pour les modèles de donnees et type pour les unions et les utilitaires. C'est une convention d'équipe, pas une regle technique.

Partager des types entre frontend et backend

Dans un monorepo, le pattern classique est un package partage :

packages/
  shared/
    src/
      types/
        user.ts
        product.ts
        api.ts
      schemas/
        user.ts       # schemas Zod
      index.ts
    package.json
  frontend/
    src/
    package.json
  backend/
    src/
    package.json

Le package shared contient les types et les schemas Zod. Le frontend et le backend importent depuis @myapp/shared.

typescript// packages/shared/src/schemas/user.ts
import { z } from "zod"

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "user"]),
})

export type User = z.infer<typeof UserSchema>
export type CreateUserInput = z.input<typeof UserSchema.omit({ id: true })>
typescript// packages/backend/src/api/users.ts
import { UserSchema, type CreateUserInput } from "@myapp/shared"

// packages/frontend/src/api/users.ts
import type { User, CreateUserInput } from "@myapp/shared"

Les schemas vivent dans shared parce que le backend les utilise pour la validation et le frontend pour le typage. Un seul endroit a maintenir.

Structure d'un vrai projet

Voici la structure que j'utilise pour un projet de taille moyenne :

src/
  features/
    auth/
      types.ts            # LoginInput, Session, AuthError
      login.ts
      register.ts
    places/
      types.ts            # Place, CreatePlaceInput, PlaceFilter
      PlaceCard.tsx
      PlaceList.tsx
      api.ts
  shared/
    types/
      api.ts              # Pagination, ApiResponse<T>, ApiError
      database.ts         # BaseEntity, Timestamps
    schemas/
      pagination.ts       # PaginationSchema reutilise partout
  lib/
    database.ts           # types inferes par l'ORM, pas manuels

Les regles :

  • Les types spécifiques a une feature sont dans features/xxx/types.ts
  • Les types partages sont dans shared/types/
  • Les types générés par l'ORM restent dans lib/ — on ne les redefinit pas manuellement
  • Pas de barrel files dans features/

Résumé

  • Colocalise les types avec le code qui les utilise — reserve shared/ pour les types partages
  • Les barrel files (index.ts) posent des problèmes de circular imports, tree-shaking et performance IDE
  • Pas de prefixe I — utilise Props pour les composants React, Input/Response/Error pour les APIs
  • interface pour les objets et contrats, type pour les unions et types derives
  • Dans un monorepo, un package shared avec les schemas Zod et types évité la duplication

Article précédent : 03 - Variables d'environnement type-safe

Article suivant : 05 - Typer une API REST

Sources

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