Front-end angularforall.com

- Types et Interfaces TypeScript : quand les utiliser

Typescript Type-Vs-Interface Type-Aliases Interfaces Declaration-Merging Discriminated-Unions Utility-Types Mapped-Types Conditional-Types Template-Literal-Types Generics Strict-Mode
Types et Interfaces TypeScript : quand les utiliser

type vs interface TypeScript : declaration merging, discriminated unions, utility types Pick/Omit, mapped/conditional types et conventions Angular/React.

Pourquoi cette question revient tout le temps

« Faut-il utiliser type ou interface ? » est probablement la question la plus posée par les développeurs TypeScript depuis dix ans. La raison est simple : sur les cas les plus courants (modéliser un objet, typer des props React, décrire une réponse API), les deux fonctionnent. Le choix paraît esthétique. Mais sur les cas non-triviaux — unions discriminées, declaration merging, types conditionnels, étendre des libs tierces — l'un des deux brille et l'autre ne fonctionne pas.

Cet article répond à la question définitivement, avec une règle pratique : les types alias couvrent 90 % des cas et restent l'outil par défaut ; les interfaces sont réservées aux trois situations précises où elles offrent un vrai bénéfice. À la fin, vous saurez quel mot-clé utiliser dans chaque contexte sans avoir à hésiter, et vous connaîtrez les utility types et patterns avancés (discriminated unions, conditional types, template literal types) qui font la puissance du système de types modernes de TypeScript.

Ce que cet article couvre

  • Un tableau comparatif exhaustif des différences pratiques.
  • La syntaxe de base : déclaration, extension, composition.
  • Les deux super-pouvoirs exclusifs : declaration merging côté interface, unions/intersections/types conditionnels côté type.
  • Les discriminated unions — le pattern le plus expressif pour modéliser des états.
  • Les 7 utility types essentiels (Pick, Omit, Partial, Required, Record, Exclude, Extract).
  • Les types mappés, conditionnels et template literal — la pointe de l'iceberg.
  • Une règle de décision claire selon le contexte (props React, API DTO, classes Angular).
  • Les conventions des écosystèmes Angular, React, NestJS.
À retenir : en 2026, la communauté TypeScript converge vers « type par défaut, interface dans trois cas précis ». C'est aussi la recommandation officielle de l'équipe TypeScript depuis le post de blog de 2022 « Types vs Interfaces ». Vous saurez exactement quels sont ces trois cas en finissant cet article.

Le contexte historique en bref

Quand TypeScript est sorti en 2012, interface existait depuis la première version — héritage direct du modèle objet de C# (Anders Hejlsberg, designer de C#, est aussi le créateur de TypeScript). Les types alias (type) sont arrivés ensuite et se sont enrichis de fonctionnalités au fil des années : unions en 1.4, intersections en 1.6, types mappés en 2.1, types conditionnels en 2.8, template literal types en 4.1. Aujourd'hui, type est strictement plus expressif que interface — mais cette dernière conserve son rôle pour les contrats d'objets et le declaration merging.

Tableau comparatif type vs interface

Fonctionnalitétypeinterface
Définir la forme d'un objet
Étendre un type existant& intersectionextends
Implémenté par une classe (implements)✓ (limité)✓ (idiomatique)
Declaration merging (fusion automatique)
Types primitifs renommés (type Id = string)
Unions (A | B)
Intersections explicites (A & B)
Tuples ([string, number])
Mapped types ({ [K in keyof T]: ... })
Conditional types (T extends U ? X : Y)
Template literal types
Génériques
Performance compilateur (gros projets)Légèrement meilleur
Lisibilité dans les erreursParfois inline-ésNom préservé

Le tableau révèle l'asymétrie : interface a un seul super-pouvoir (declaration merging), type en a six. C'est la base de la règle « type par défaut ».

Syntaxe de base — déclaration et lecture

Définir un objet — équivalence parfaite

// Avec type
type User = {
  id: string;
  name: string;
  email: string;
  isAdmin?: boolean;
};

// Avec interface — exactement la même chose
interface User {
  id: string;
  name: string;
  email: string;
  isAdmin?: boolean;
}

// Usage identique
function greet(user: User): string {
  return `Hello, ${user.name}`;
}

Renommer un primitif — seul type fonctionne

// type peut nommer n'importe quel type, y compris primitifs
type UserId = string;
type Status = 'pending' | 'active' | 'blocked';
type Coordinates = [number, number]; // tuple
type AsyncFn = () => Promise<void>;

// interface ne peut pas — strictement réservé aux objets
// interface UserId = string;  // ❌ erreur de syntaxe

Implémenter par une classe

// interface — idiomatique
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

class UserRepository implements Repository<User> {
  async findById(id: string)  { /* ... */ return null; }
  async save(user: User)      { /* ... */ return user; }
  async delete(id: string)    { /* ... */ }
}

// type — fonctionne aussi mais moins idiomatique
type Repo<T> = {
  findById(id: string): Promise<T | null>;
};
class X implements Repo<User> { /* ... */ }

Extension : extends vs intersection

Avec interface — extends

interface User {
  id: string;
  name: string;
}

interface Permissions {
  canEdit: boolean;
  canDelete: boolean;
}

// Extension simple
interface AdminUser extends User {
  role: 'admin';
  permissions: string[];
}

// Extension multiple
interface FullAdminUser extends User, Permissions {
  twoFactorEnabled: boolean;
}

Avec type — intersection &

type User = { id: string; name: string };
type Permissions = { canEdit: boolean; canDelete: boolean };

// Intersection simple
type AdminUser = User & {
  role: 'admin';
  permissions: string[];
};

// Intersection multiple
type FullAdminUser = User & Permissions & {
  twoFactorEnabled: boolean;
};

Différence subtile : détection des conflits

interface A { value: string; }
interface B { value: number; }

// ❌ extends détecte le conflit à la compilation — erreur immédiate
interface AB extends A, B {}
// Error: Interface 'AB' cannot simultaneously extend types 'A' and 'B'.

// Avec type, l'intersection produit un type "never" pour la propriété conflictuelle
type AB = A & B; // pas d'erreur ici…
const x: AB = { value: ??? }; // mais ici : Type 'string | number' is not assignable to 'never'

Pour les API publiques destinées à être étendues par les consommateurs, extends offre des messages d'erreur plus immédiats et plus clairs. C'est l'un des trois cas qui justifie interface.

Declaration merging — l'unique super-pouvoir de interface

Deux interfaces du même nom dans le même scope fusionnent automatiquement en une seule. C'est unique à interface et indispensable pour étendre des types globaux ou des types de libs tierces.

// Première déclaration
interface User {
  id: string;
  name: string;
}

// Plus tard, dans un autre fichier ou plugin
interface User {
  avatarUrl?: string;
}

// Résultat — User a maintenant id, name ET avatarUrl
const u: User = {
  id: '1',
  name: 'Alice',
  avatarUrl: '/avatar.png', // ✓ accessible
};

Cas concret #1 : étendre Window

// types/global.d.ts
interface Window {
  myApp: {
    version: string;
    config: Record<string, unknown>;
  };
}

// Ailleurs dans le code
window.myApp = { version: '1.0', config: {} }; // ✓ typage strict

Cas concret #2 : enrichir Express Request

// types/express.d.ts — pratique courante dans les apis Node
import 'express';

declare global {
  namespace Express {
    interface Request {
      user?: { id: string; roles: string[] };
      requestId: string;
    }
  }
}

// Ailleurs
app.use((req, res, next) => {
  req.requestId = crypto.randomUUID(); // ✓ typé
  next();
});

Cas concret #3 : permettre l'extension par plugins

// Lib UI
interface ButtonProps {
  label: string;
  onClick: () => void;
}

// Plugin tiers
interface ButtonProps {
  analyticsId?: string; // ajout sans toucher au fichier source
}

Sans declaration merging, ces patterns nécessiteraient soit de modifier le fichier source de la lib, soit de créer un nouveau type qui étend l'ancien — moins ergonomique. Si vous écrivez une lib partagée ou enrichissez des types globaux, utilisez interface.

Unions et intersections — l'unique super-pouvoir de type

Union de littéraux — l'usage le plus fréquent

type Status = 'idle' | 'loading' | 'success' | 'error';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Locale = 'fr' | 'en' | 'es' | 'de';

// Une fonction ne peut recevoir que les valeurs autorisées
function setStatus(s: Status) { /* ... */ }
setStatus('loading'); // ✓
setStatus('done');    // ❌ Type 'done' is not assignable to type Status

Union d'objets — modéliser des variantes

// Une notification peut être de 3 formes différentes
type Notification =
  | { type: 'message'; from: string; text: string }
  | { type: 'like'; from: string; postId: string }
  | { type: 'system'; level: 'info' | 'warn'; text: string };

function render(notif: Notification) {
  if (notif.type === 'message') {
    // TypeScript narrow le type — on a accès à .text
    console.log(`Message de ${notif.from} : ${notif.text}`);
  }
  // …
}

Intersection

type Auditable = { createdAt: Date; updatedAt: Date };
type Stamped<T> = T & Auditable;

type StampedUser = Stamped<User>; // User + createdAt + updatedAt

interface ne peut pas exprimer ces unions. C'est la raison principale d'utiliser type dès que vous modélisez quelque chose qui peut prendre plusieurs formes.

Discriminated Unions — le pattern le plus puissant

Une discriminated union (ou union taggée) est une union dont chaque variante porte un littéral commun qui sert de discriminant. TypeScript utilise ce littéral pour narrower automatiquement le type dans une condition.

// Result<T> — type de retour qui force la gestion d'erreur
type Result<T, E = string> =
  | { kind: 'ok';    value: T }
  | { kind: 'error'; error: E };

async function fetchUser(id: string): Promise<Result<User>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { kind: 'error', error: `HTTP ${res.status}` };
    const value = await res.json();
    return { kind: 'ok', value };
  } catch (e) {
    return { kind: 'error', error: (e as Error).message };
  }
}

// Consommateur — obligation de gérer les deux cas
const result = await fetchUser('42');
if (result.kind === 'ok') {
  console.log(result.value.name); // ✓ result.value typé User
} else {
  console.error(result.error);    // ✓ result.error typé string
}
// Pas de result.value ici — TypeScript le sait, refuse l'accès

Pattern Redux/NgRx — actions

type CartAction =
  | { type: 'ADD_ITEM';    item: CartItem }
  | { type: 'REMOVE_ITEM'; id: string }
  | { type: 'CLEAR_CART' }
  | { type: 'APPLY_COUPON'; code: string; discount: number };

function reducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':     return { ...state, items: [...state.items, action.item] };
    case 'REMOVE_ITEM':  return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'CLEAR_CART':   return { ...state, items: [] };
    case 'APPLY_COUPON': return { ...state, coupon: { code: action.code, discount: action.discount } };
    // TypeScript exige l'exhaustivité — si on oublie un case, erreur
  }
}

C'est l'un des patterns les plus puissants du système de types TypeScript. interface ne peut pas exprimer ces unions — vous écrivez systématiquement avec type.

Utility Types essentiels — Pick, Omit, Partial...

TypeScript fournit nativement une vingtaine de types utilitaires qui dérivent un nouveau type d'un type existant. En connaître 7 couvre 90 % des besoins.

interface User {
  id: string;
  name: string;
  email: string;
  password: string;
  createdAt: Date;
}

// 1. Partial<T> — toutes les props deviennent optionnelles
type UpdateUserDto = Partial<User>;
// { id?: string; name?: string; email?: string; password?: string; createdAt?: Date }

// 2. Required<T> — toutes deviennent obligatoires (inverse de Partial)
type StrictUser = Required<Partial<User>>;

// 3. Readonly<T> — toutes deviennent en lecture seule
type ImmutableUser = Readonly<User>;

// 4. Pick<T, K> — sélectionne des propriétés
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;

// 5. Omit<T, K> — exclut des propriétés (très utile pour cacher password)
type SafeUser = Omit<User, 'password'>;

// 6. Record<K, V> — objet avec clés K et valeurs V
type Translations = Record<Locale, string>;
// { fr: string; en: string; es: string; de: string }

// 7. Exclude<T, U> et Extract<T, U> — filtrent une union
type Status = 'idle' | 'loading' | 'success' | 'error';
type ActiveStatus = Exclude<Status, 'idle'>; // 'loading' | 'success' | 'error'
type Terminal = Extract<Status, 'success' | 'error'>; // 'success' | 'error'

Utility Types pour les fonctions

function createUser(name: string, email: string): Promise<User> { /* ... */ }

// Récupérer les types de paramètres
type CreateArgs = Parameters<typeof createUser>; // [string, string]

// Récupérer le type de retour
type CreateReturn = ReturnType<typeof createUser>; // Promise<User>

// Récupérer le type déballé d'une Promise
type CreatedUser = Awaited<CreateReturn>; // User

Ces utilitaires sont presque tous écrits avec type dans le code source de TypeScript — ils utilisent des types mappés et conditionnels qui ne sont pas exprimables avec interface.

Combinaisons utiles d'Utility Types

// Pattern fréquent : DTO de mise à jour (toutes optionnelles, sauf l'id)
type UpdateDto<T extends { id: string }> = { id: string } & Partial<Omit<T, 'id'>>;

// Pattern : créer un dictionnaire indexé par un littéral
type Translations = Record<'fr' | 'en' | 'es', Record<string, string>>;

// Pattern : type d'une fonction async sans la partie Promise
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type UserData = Unwrap<ReturnType<typeof fetchUser>>;

Types et interfaces génériques

Les deux supportent les génériques avec la même syntaxe. Les contraintes (T extends U) et les valeurs par défaut (T = string) fonctionnent identiquement.

// interface générique
interface Repository<T, K extends string | number = string> {
  findById(id: K): Promise<T | null>;
  save(entity: T): Promise<T>;
}

// type générique équivalent
type Repository<T, K extends string | number = string> = {
  findById(id: K): Promise<T | null>;
  save(entity: T): Promise<T>;
};

// Usage identique
class UserRepo implements Repository<User> { /* ... */ }
class OrderRepo implements Repository<Order, number> { /* ... */ }

Mappings génériques avancés — type uniquement

// Rendre toutes les props nullables récursivement — impossible avec interface
type DeepNullable<T> = {
  [K in keyof T]: T[K] extends object
    ? DeepNullable<T[K]>
    : T[K] | null;
};

type NullableUser = DeepNullable<User>;

Types avancés : mapped, conditional, template literal

Mapped types — itérer sur les clés

// Réimplémentation de Partial — pour comprendre comment ça marche
type MyPartial<T> = { [K in keyof T]?: T[K] };

// Rendre une seule propriété optionnelle
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

type UserDraft = PartialBy<User, 'id'>;
// Toutes les props sont obligatoires SAUF id qui est optionnel

Conditional types — types qui dépendent d'autres types

// IsString — renvoie true ou false selon le type passé
type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>; // true
type B = IsString<42>;       // false

// NonNullable<T> — retire null et undefined d'un type
type NonNullable<T> = T extends null | undefined ? never : T;

// Extraire le type des éléments d'un tableau
type Unpack<T> = T extends (infer U)[] ? U : T;
type Item = Unpack<string[]>; // string

Template literal types — manipulation de chaînes au niveau types

// Définir un format précis
type EventName = `on${Capitalize<string>}`;
const valid: EventName = 'onClick';     // ✓
// const bad: EventName = 'click';       // ❌

// Combinaison
type Locale = 'fr' | 'en';
type Region = 'FR' | 'CA' | 'US';
type LocaleCode = `${Locale}-${Region}`;
// 'fr-FR' | 'fr-CA' | 'fr-US' | 'en-FR' | 'en-CA' | 'en-US'

Ces trois familles (mapped, conditional, template literal) sont exclusives à type. Elles forment la « zone magique » du typage TypeScript moderne — ce qui permet à des libs comme tRPC, Zod, ou Prisma de fournir une autocomplete parfaite sur des structures dynamiques.

Règles de décision pratiques par contexte

Les 3 cas où interface est le bon choix

  1. API publique d'une lib partagée — les noms d'interface restent visibles dans les messages d'erreur du compilateur et la documentation IDE.
  2. Étendre un type tiers ou global — declaration merging sur Window, Express.Request, Vue.GlobalComponents, etc. Impossible avec type.
  3. Contrat implémenté par plusieurs classesclass X implements IRepo<User>. Idiomatique avec interface, fonctionne aussi avec type mais culturellement moins répandu.

Tous les autres cas — utilisez type

  • Modéliser un état avec variantes (Status, Notification, Result) → type avec union.
  • Renommer un primitif (UserId, Email, Timestamp) → type uniquement.
  • Composer plusieurs types via intersection → type.
  • Dériver un type d'un autre via Utility Types → type.
  • Utiliser mapped/conditional/template literal types → type.
  • Modéliser une discriminated union (actions Redux, états de chargement) → type.

Conventions Angular et React

Angular

Angular utilise massivement interface dans sa documentation officielle : interface User, interface HttpRequest. C'est cohérent avec son orientation OOP (services injectés, classes pour les composants). Sur un projet Angular, suivre la convention interface pour les contrats d'objets et de services, type pour les unions et les utility types est généralement le plus clair.

// Convention Angular typique
interface User {
  id: string;
  name: string;
  email: string;
}

interface UserService {
  getUser(id: string): Observable<User>;
  updateUser(user: User): Observable<User>;
}

type LoadState = 'idle' | 'loading' | 'loaded' | 'error';
type Result<T> = { ok: true; data: T } | { ok: false; error: string };

React

La communauté React + TypeScript utilise interface Props par convention historique, malgré la recommandation officielle TypeScript qui penche désormais vers type. Les deux fonctionnent parfaitement. Pour un nouveau projet, choisissez et soyez cohérent — peu importe lequel.

// React convention historique
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
}

export function Button({ label, onClick, variant = 'primary' }: ButtonProps) {
  return <button onClick={onClick} className={variant}>{label}</button>;
}

// Version type — équivalent strict
type ButtonProps2 = {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
};

NestJS

NestJS, fortement orienté décorateurs et DI, utilise interface pour les contrats de services et class pour les DTO (afin de bénéficier de class-validator). type apparaît pour les unions et utility types. C'est le même pattern qu'Angular avec une touche backend.

Libs modernes : Zod, tRPC, Prisma

Toute la nouvelle vague de libs TypeScript (Zod pour la validation runtime, tRPC pour les API end-to-end typées, Prisma pour les ORM) utilise massivement type + types conditionnels + template literal types pour offrir une autocomplete quasi-magique. Aucune de ces libs n'aurait été possible avec interface seul. C'est l'argument décisif en faveur de type pour quiconque travaille sur des outils modernes.

Pièges classiques et bonnes pratiques

À faire
  • Définir une convention d'équipe (par défaut type ou interface) et la documenter dans votre style guide.
  • Préférer type pour les unions, intersections, utility types et types primitifs renommés.
  • Utiliser interface pour les contrats orientés objet et l'extension de types tiers.
  • Activer le mode strict: true dans tsconfig — la majorité des bénéfices viennent de là.
  • Préfixer les interfaces par I est déconseillé par l'équipe TypeScript depuis 2020.
  • Réutiliser les Utility Types avant d'écrire un type from-scratch.
À éviter
  • Mélanger les conventions au sein d'un même fichier — choisissez l'un ou l'autre par cohérence.
  • Utiliser any au lieu de chercher le bon type — le compilateur perd toute valeur.
  • Recréer un Utility Type qui existe déjà (réimplémenter Partial, Pick, etc.).
  • Utiliser interface pour des unions — impossible, vous serez bloqué.
  • Activer noImplicitAny: false en pensant gagner du temps — vous perdez la valeur de TypeScript.
  • Oublier le mot-clé readonly sur les types immuables (DTO API, props React).

Mini-projet appliqué — formulaire d'inscription typé

Pour matérialiser la règle « type par défaut, interface dans trois cas précis », voici un cas d'usage réel : un formulaire d'inscription avec états multiples (idle, validating, error, success) et contrat React Props. On verra que le bon choix entre type et interface dépend du rôle de chaque déclaration, pas d'une préférence stylistique.

1. État du formulaire — type avec discriminated union

L'état du formulaire n'est pas un objet figé : il a 4 variantes mutuellement exclusives. C'est exactement le cas où type est obligatoire (les unions ne sont pas exprimables avec interface).

// type = union de 4 variantes discriminées par "status"
type RegisterFormState =
    | { status: 'idle' }
    | { status: 'validating'; values: RegisterFormValues }
    | { status: 'error'; values: RegisterFormValues; errors: FormErrors }
    | { status: 'success'; userId: string };

// Chaque variante n'expose QUE les champs pertinents
// Impossible d'accéder à state.errors si state.status === 'idle' — TypeScript bloque
function renderForm(state: RegisterFormState) {
    switch (state.status) {
        case 'idle':       return <EmptyForm />;
        case 'validating': return <SpinnerOverlay values={state.values} />;
        case 'error':      return <ErrorForm values={state.values} errors={state.errors} />;
        case 'success':    return <SuccessMessage userId={state.userId} />;
    }
}
Pourquoi type ici : les états sont fermés (4 variantes connues) et mutuellement exclusifs. interface avec une propriété optionnelle errors?: FormErrors casserait la garantie d'exclusivité — on pourrait accéder à errors en état idle. Pour comprendre ce pattern en profondeur, voir le narrowing TypeScript par discriminant.

2. Données du formulaire — type dérivé via utility types

Plutôt que de redéfinir manuellement RegisterFormValues à chaque endroit, on le dérive d'un type source unique. Pour creuser, lire le guide des utility types essentiels.

// Source of truth : le modèle User complet
type User = {
    id: string;
    email: string;
    password: string;
    fullName: string;
    avatarUrl: string | null;
    role: 'admin' | 'member' | 'guest';
    createdAt: Date;
};

// Le formulaire d'inscription ne demande que 3 champs
type RegisterFormValues = Pick<User, 'email' | 'password' | 'fullName'>;

// Les erreurs sont indexées par les mêmes clés — sécurité de typage end-to-end
type FormErrors = Partial<Record<keyof RegisterFormValues, string>>;

// Si on retire 'fullName' du modèle User, TypeScript échoue à la compilation
// dans TOUS les composants qui utilisent RegisterFormValues

3. Props du composant React — interface par convention

La convention React + TypeScript veut interface pour les Props. Cela offre des messages d'erreur plus lisibles dans les IDE et facilite l'extension par extends dans les composants composés.

// interface = contrat de composant, convention React
interface RegisterFormProps {
    state: RegisterFormState;
    onSubmit: (values: RegisterFormValues) => Promise<void>;
    onChange: (field: keyof RegisterFormValues, value: string) => void;
    className?: string;
}

// Extension naturelle pour un sous-composant
interface AdminRegisterFormProps extends RegisterFormProps {
    onRoleChange: (role: User['role']) => void;
}

const RegisterForm: React.FC<RegisterFormProps> = ({ state, onSubmit, onChange }) => {
    // ...
};

4. Contrat de service — interface implémenté par des classes

Le service d'inscription est un contrat que plusieurs implémentations doivent respecter (mock pour les tests, réel pour la prod). C'est le cas d'usage canonique de interface avec implements.

// interface = contrat implémenté
interface IRegisterService {
    validate(values: RegisterFormValues): Promise<FormErrors | null>;
    register(values: RegisterFormValues): Promise<{ userId: string }>;
}

// Implémentation prod
class ApiRegisterService implements IRegisterService {
    async validate(values: RegisterFormValues): Promise<FormErrors | null> {
        const res = await fetch('/api/validate-register', { method: 'POST', body: JSON.stringify(values) });
        const data = await res.json();
        return data.errors ?? null;
    }
    async register(values: RegisterFormValues): Promise<{ userId: string }> {
        const res = await fetch('/api/register', { method: 'POST', body: JSON.stringify(values) });
        if (!res.ok) throw new Error(`Register failed: ${res.status}`);
        return res.json();
    }
}

// Implémentation tests — même contrat, comportement contrôlé
class MockRegisterService implements IRegisterService {
    async validate(values: RegisterFormValues): Promise<FormErrors | null> {
        return values.email.includes('@') ? null : { email: 'Email invalide' };
    }
    async register(_values: RegisterFormValues): Promise<{ userId: string }> {
        return { userId: 'mock-user-id' };
    }
}

5. Récapitulatif des choix dans le projet

ÉlémentChoixPourquoi
RegisterFormStatetypeUnion discriminée (impossible avec interface)
User (modèle)typeSource unique dérivée par utility types ailleurs
RegisterFormValuestypeDérivé via Pick (utility type)
FormErrorstypeComposé via Partial<Record<...>>
RegisterFormPropsinterfaceConvention React, extension par sous-composant
IRegisterServiceinterfaceContrat implémenté par plusieurs classes

Ce projet illustre une réalité production : les deux constructions cohabitent dans le même fichier, choisies selon le rôle, pas selon une préférence d'équipe. La règle de décision tient en une question : « cette déclaration sera-t-elle implémentée par une classe ou étendue par convention React/lib ? » Si oui → interface. Sinon → type. Pour aller plus loin sur la composition de types complexes, voir le guide complet des génériques et les decorators pour la validation déclarative.

Conclusion

Le débat type vs interface n'est pas religieux — il y a des règles claires que la communauté TypeScript et l'équipe officielle ont validées au fil des années. La synthèse en une phrase : « type par défaut, interface dans trois cas précis (lib publique, declaration merging, contrat implémenté par des classes) ». Cette règle couvre 100 % des situations que vous rencontrerez en pratique, et vous évite de perdre du temps à hésiter à chaque nouveau fichier.

La vraie puissance du système de types TypeScript ne vient pas du choix entre type et interface — elle vient des constructions exclusives à type : unions, intersections, mapped types, conditional types, template literal types, et discriminated unions. Maîtriser ces patterns transforme votre code : moins de bugs, meilleure documentation implicite, autocomplete d'un niveau quasi-magique. C'est ce qui distingue un développeur TypeScript débutant qui ajoute des annotations : string partout d'un développeur senior qui modélise le domaine métier au niveau du système de types.

Le conseil pratique à retenir au-delà de la règle de décision : explorez chaque jour un nouveau pattern du système de types. Les discriminated unions remplacent les boolean-flag-soup. Les utility types remplacent les types redéfinis. Les template literal types permettent de typer des chaînes structurées (clés d'API, codes locale, paths de routes). À chaque pattern adopté, votre codebase devient plus expressive et plus sûre — sans une ligne supplémentaire de runtime.

Récapitulatif des bonnes pratiques :
  • type par défaut, interface dans trois cas précis
  • interface pour lib publique, declaration merging, contrat de classe
  • type pour unions, intersections, primitifs renommés, utility types
  • Toujours activer strict: true dans tsconfig.json
  • Maîtriser les 7 utility types essentiels : Partial, Required, Readonly, Pick, Omit, Record, Exclude/Extract
  • Utiliser les discriminated unions pour les états et les variantes
  • Ne pas préfixer les interfaces par I (déconseillé officiellement)
  • Définir et documenter la convention d'équipe — peu importe laquelle, l'important est la cohérence
  • Réutiliser les utility types existants avant d'en écrire un nouveau
  • Explorer mapped/conditional/template literal types pour les cas avancés

Partager