📍 Guide principal de la série : TypeScript moderne — du JS au système de types industriel
Ce tutoriel détaille les différences exactes entre
typeetinterfaceet donne une règle de choix tenable sur un vrai projet.
Introduction
« Faut-il utiliser type ou interface ? » revient dans toutes les revues de code des projets TypeScript. Sur un objet simple, les deux paraissent interchangeables, ce qui pousse souvent les équipes à trancher au hasard ou à arbitrer par préférence esthétique. Pourtant, les deux constructions ont des règles différentes sur la fusion de déclaration, l’extension, la performance perçue, et le périmètre des formes qu’elles peuvent décrire. Ce tutoriel pose les règles exactes, les met en démonstration avec du code testable, et propose une heuristique claire que vous pourrez appliquer sans hésiter dans la suite de votre carrière TypeScript.
Prérequis
- Un projet TypeScript fonctionnel (voir le tutoriel d’installation si nécessaire).
- TypeScript 5.0 ou supérieur. Les exemples sont validés sur la version 6.0.
- Confort avec les notions de base : annotations, fonctions typées, modules.
- Temps estimé : 30 à 40 minutes.
Étape 1 — Le cas commun : un objet simple
Pour décrire la forme d’un objet, type et interface produisent un résultat strictement équivalent. Le compilateur les traite de la même façon pour vérifier l’assignabilité, l’autocomplétion, et l’inférence. On commence par poser les deux versions côte à côte pour observer cette équivalence.
// Version interface
interface UserI {
id: string;
email: string;
}
// Version type alias
type UserT = {
id: string;
email: string;
};
// Les deux acceptent la même valeur
const a: UserI = { id: "u1", email: "a@x.io" };
const b: UserT = { id: "u2", email: "b@x.io" };
Si l’on ne s’arrête qu’à ce cas, le choix est arbitraire. Mais dès que l’on s’éloigne du cas trivial, les divergences apparaissent. C’est l’objet des étapes suivantes : explorer chacune des situations où les deux constructions ne sont plus interchangeables.
Étape 2 — Fusion de déclaration : exclusivité des interfaces
La fusion de déclaration est la première différence majeure. Une interface peut être déclarée plusieurs fois dans le même scope : TypeScript fusionne automatiquement les propriétés. Un type, en revanche, ne tolère pas la redéclaration ; toute deuxième déclaration produit une erreur de compilation.
// ✓ Fusionnée automatiquement
interface Person { name: string }
interface Person { age: number }
const p: Person = { name: "A", age: 42 };
// ✗ Erreur : Duplicate identifier 'PersonT'
type PersonT = { name: string };
type PersonT = { age: number };
Cette mécanique n’est pas une curiosité de bibliothèque : elle est centrale dès qu’on veut étendre une API tierce. Le type Window dans le DOM, le type Express.Request côté serveur, ou le type NodeJS.ProcessEnv sont déclarés en interface précisément pour qu’un consommateur puisse ajouter ses propres propriétés sans avoir à toucher au code source de la bibliothèque.
Exemple concret avec Express :
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
userId?: string;
requestId: string;
}
}
}
export {};
Une fois ce fichier inclus, toute fonction middleware qui reçoit req: Request bénéficie de l’autocomplétion sur req.userId et req.requestId. Avec un type, cette extension serait impossible : on serait obligé de réécrire la signature complète à chaque endroit, ou de patcher le module en profondeur.
Conséquence pratique : pour une API publique exposée par une bibliothèque (paquet npm), on privilégie interface pour permettre cette extension. Pour des types internes d’application, l’absence de fusion n’est pas un manque — c’est même un garde-fou contre les redéclarations involontaires.
Étape 3 — Extension : extends contre intersection
Pour composer un nouveau type à partir d’un existant, interface utilise extends tandis que type recourt à l’opérateur d’intersection &. Sur les cas simples, les deux produisent un résultat équivalent.
interface BaseAddress {
street: string;
city: string;
}
interface AddressWithUnit extends BaseAddress {
unit: string;
}
type Colorful = { color: string };
type Circle = { radius: number };
type ColorfulCircle = Colorful & Circle;
Une différence sensible apparaît quand les types fusionnés contiennent une propriété de même nom mais de type incompatible. Avec interface extends, le compilateur émet immédiatement une erreur explicite sur le conflit. Avec une intersection sur des type, le résultat est silencieusement never sur la propriété conflictuelle, ce qui peut passer inaperçu jusqu’à ce qu’on essaie d’assigner une valeur.
interface A { id: string }
interface B extends A {
// ✗ Erreur immédiate : 'id' incompatible
id: number;
}
type AT = { id: string };
type BT = { id: number };
type C = AT & BT;
// C['id'] vaut 'never' — le compilateur attendra l'utilisation pour signaler.
declare const c: C;
c.id; // type : never
Sur une grande base de code, le message immédiat de la version interface est préférable : on attrape l’erreur à sa source. La version type demande plus de discipline et peut produire des diagnostics moins lisibles. Inversement, les intersections permettent de composer des types non-objets (par exemple une union & un type avec discriminant) que interface extends ne sait pas exprimer.
Étape 4 — Périmètre des formes : tout vs objet seul
Une interface ne décrit que des objets, des fonctions, et des constructeurs. Elle ne peut pas représenter directement une union, un tuple, une primitive, ou un type littéral. Un type peut tout faire. C’est l’argument décisif dans la majorité des cas où l’on ne peut pas s’en passer.
// ✓ types : toutes les formes
type Status = "idle" | "loading" | "success" | "error";
type ID = string | number;
type Pair = [string, number];
type Nullable<T> = T | null;
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
// ✗ interfaces : impossible
interface Status = "idle" | "loading"; // SyntaxError
Conséquence : les unions discriminées, les tuples nommés, les types utilitaires complexes, les types conditionnels et les mapped types passent obligatoirement par type. C’est la raison principale pour laquelle une base de code TypeScript moderne contient beaucoup de type, même quand elle privilégie interface pour les objets publics.
Étape 5 — Implémentation par une classe
Les deux constructions peuvent servir de contrat implements pour une classe, à condition de décrire une forme d’objet. Une union ne peut pas être implémentée par une classe parce qu’elle ne décrit pas une forme unique.
interface Logger {
log(message: string): void;
}
type LoggerT = {
log(message: string): void;
};
class ConsoleLogger implements Logger {
log(message: string) {
console.log(`[log] ${message}`);
}
}
class FileLogger implements LoggerT {
log(message: string) {
/* écrit dans un fichier */
}
}
// ✗ Impossible : union pas implémentable
type UnionLogger = Logger | { warn(message: string): void };
// class X implements UnionLogger {} // erreur
En pratique, quand on définit un contrat destiné à être implémenté par plusieurs classes, l’usage d’interface communique mieux l’intention au lecteur — c’est la convention. Mais TypeScript n’impose rien et accepte les deux.
Étape 6 — Génériques
Les deux constructions acceptent des paramètres de type, avec les mêmes possibilités de contraintes et de défauts. Là encore, l’équivalence est presque parfaite pour décrire des génériques d’objet.
interface Box<T> {
contents: T;
}
type BoxT<T> = {
contents: T;
};
interface ApiResult<T, E = Error> {
data: T | null;
error: E | null;
}
type Result<T, E = Error>
= { kind: "ok"; value: T }
| { kind: "err"; error: E };
La différence reste celle vue plus haut : un type peut décrire une union générique (Result dans l’exemple), une interface non. Pour un conteneur générique d’objet, les deux sont interchangeables.
Étape 7 — Performance perçue et messages d’erreur
L’équipe TypeScript a publié à plusieurs reprises des indications sur les différences de performance. Sur un projet de taille moyenne, la différence n’est jamais perceptible et ne doit pas guider le choix. Sur des bibliothèques de très haut niveau qui composent intensivement des types (Zod, tRPC, ts-toolbelt), les interfaces se révèlent parfois légèrement plus performantes pour le compilateur parce qu’elles sont mises en cache différemment en interne, et leurs messages d’erreur sont souvent plus lisibles : le compilateur affiche le nom de l’interface plutôt que la forme inline.
// Avec une interface, un message d'erreur typique cite "User"
interface User { id: string; email: string }
const u: User = { id: "1", emai: "x" };
// Erreur : Object literal may only specify known properties,
// and 'emai' does not exist in type 'User'.
// Avec un type complexe, on peut tomber sur des messages plus
// verbeux mentionnant la forme déroulée plutôt que le nom.
Cette différence de lisibilité est réelle sur les types complexes, mais elle ne justifie pas une réécriture systématique : sur 99 % du code applicatif, elle reste imperceptible.
Étape 8 — Cas particuliers à connaître
Plusieurs idiomes valent la peine d’être connus pour ne pas se piéger.
Les méthodes vs les fonctions-propriétés. Une méthode déclarée avec la syntaxe doSomething(): void est bivariante sur les paramètres dans le typage par défaut. Une propriété typée comme une fonction avec une flèche doSomething: () => void est strictement contravariante sous strictFunctionTypes. Pour un API contract sensible à la sûreté, la version flèche est plus rigoureuse.
// Méthode : variance permissive
interface MethodAPI {
handle(event: Event): void;
}
// Propriété fonction : variance stricte
interface FunctionAPI {
handle: (event: Event) => void;
}
Le mot-clé this dans les interfaces. Une interface peut référencer this dans une signature pour exprimer un retour polymorphe, utile pour les API fluent. Un type alias peut aussi l’exprimer mais l’écriture est moins naturelle.
interface QueryBuilder {
where(field: string): this;
orderBy(field: string): this;
limit(n: number): this;
}
L’export d’un type calculé. Un type peut être le résultat d’une expression de type (type Keys = keyof User, type Pair = [string, number]). Une interface ne le peut pas. Quand on dérive un type à partir d’un autre, on passe systématiquement par type.
Étape 9 — Heuristique de choix concrète
À ce stade, on dispose de tous les éléments pour formuler une règle de choix tenable. Plutôt que de retomber sur des préférences personnelles, on s’appuie sur la nature de ce que l’on décrit.
- Forme d’objet exposée dans une API publique (paquet npm, contrat partagé) →
interface. La fusion de déclaration laisse aux consommateurs la possibilité d’étendre sans patcher. - Forme d’objet interne sans intention d’être étendue → indifférent. La cohérence d’une convention d’équipe prime. Beaucoup de bases de code adoptent
interfacepar défaut pour cette catégorie. - Union, tuple, primitive, type littéral, intersection complexe →
type. C’est la seule option qui exprime ces formes. - Type dérivé via
keyof,typeof, mapped type, conditional type →type. Une interface ne peut pas être le résultat d’une expression. - Contrat d’implémentation pour une classe →
interfacepar convention, maistypefonctionne aussi.
Cette heuristique tient sur la majorité des cas. Les linters peuvent l’aider : la règle @typescript-eslint/consistent-type-definitions permet de choisir une politique par projet.
Étape 10 — Vérification et exercices d’application
Pour valider la compréhension, on met en place un mini-exercice qui force à utiliser les deux constructions selon leurs forces.
// Fichier src/contracts.ts
// 1. Une interface publique exposée par la bibliothèque
export interface ApiClient {
get<T>(url: string): Promise<T>;
post<T, B>(url: string, body: B): Promise<T>;
}
// 2. Un type union pour modéliser un état applicatif
export type RequestState<T> =
| { status: "idle" }
| { status: "pending" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
// 3. Un type dérivé pour extraire les statuts possibles
export type RequestStatus = RequestState<unknown>["status"];
// ^? "idle" | "pending" | "success" | "error"
// 4. Une interface qu'on étend depuis ailleurs
export interface AppConfig {
apiUrl: string;
}
// Plus loin, dans un autre fichier :
declare module "./contracts.js" {
interface AppConfig {
telemetryUrl: string;
}
}
On compile :
npx tsc --noEmit
Si la compilation passe sans erreur, c’est que AppConfig a bien fusionné les deux déclarations, que RequestState et RequestStatus sont reconnus comme types valides, et que ApiClient est exposable. Pour aller plus loin, on peut tenter de convertir RequestState en interface : le compilateur refusera l’union, ce qui confirme la règle de l’étape 4.
Erreurs fréquentes
| Symptôme | Cause probable | Solution |
|---|---|---|
Duplicate identifier sur un type |
Tentative de fusion sur un alias de type | Convertir en interface si la fusion est intentionnelle |
Propriété de type never inattendue |
Intersection de deux type avec une propriété conflictuelle |
Vérifier les noms de propriétés, préférer interface extends pour avoir une erreur explicite |
Impossible d’étendre Express.Request |
Tentative de fusion via type |
Utiliser declare global { namespace Express { interface Request {} } } |
| Message d’erreur très verbeux sur un objet | Type composé en intersection de plusieurs types | Donner un nom intermédiaire (type Step1 = ...) ou utiliser interface pour bénéficier du nom dans l’erreur |
Class incorrectly implements interface |
Tentative d’implémenter une union | Implémenter chaque variante séparément ou définir une interface unifiée |
Tutoriels complémentaires
- Génériques avancés — le pas suivant pour rendre vos types paramétriques.
- Utility types et mapped types — dériver des types à partir des objets que vous décrivez.
- Retour au guide principal — vue d’ensemble du langage.
FAQ
Si je dois choisir une seule règle simple, laquelle ?
interface pour les formes d’objet public, type pour tout le reste. Sur les objets internes, suivez la convention de votre équipe.
Une interface peut-elle hériter d’un type ?
Oui, avec extends, à condition que le type décrive une forme d’objet. interface X extends MonTypeObjet est légal. interface X extends MonUnion ne l’est pas.
Quelle différence de comportement à l’exécution ?
Aucune. Les deux disparaissent à la compilation. Le code émis est strictement identique. La différence est purement structurale au moment de la vérification de type.
Le linter peut-il imposer un choix uniforme ?
Oui, @typescript-eslint/consistent-type-definitions permet de forcer soit interface soit type sur les déclarations d’objet. La règle ne s’applique pas aux unions et tuples qui n’ont pas d’alternative.
Doit-on convertir tout son code en un seul style ?
Non. Un mélange documenté par usage est plus pertinent qu’une uniformisation cosmétique. La règle « interface pour les objets, type pour le reste » suffit à la quasi-totalité des cas.