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
interfacevstypeen 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 :
Circular imports. Si
UserCardimporte depuis./typesetindex.tsreexporte les deux, certains bundlers creent des cycles.Tree-shaking casse. Si tu importes
Userdepuis le barrel, le bundler peut inclureUserCard,UserListetapidans le bundle. Webpack et Rollup ont des heuristiques pour éviter ca, mais elles ne fonctionnent pas toujours.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— utilisePropspour les composants React,Input/Response/Errorpour les APIs interfacepour les objets et contrats,typepour les unions et types derives- Dans un monorepo, un package
sharedavec 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
- TypeScript Handbook - Type vs Interface
- Barrel files and why you should stop using them par Marvin Hagemeister
- Colocation par Kent C. Dodds