TypeScript : type vs interface
Quand utiliser type ? Quand utiliser interface ? Comprendre les différences pour choisir la bonne approche en TypeScript.
Publié le par Emmanuel LASTRA 3 min de lecture
Souvent présentés comme interchangeables, comme une question de préférence personnelle et de cohérence, type et interface ont en réalité des différences importantes.
Comprendre ces différences peut permettre de choisir la bonne approche pour structurer son code TypeScript de manière claire et maintenable.
Les deux servent à décrire la forme d’un objet et pour 80% des cas, ils sont effectivement interchangeables. Mais le vrai choix se fait sur les capacités supplémentaires que chacun apporte.
À première vue ils semblent identiques :
// Avec interface
interface User {
name: string
age: number
}
// Avec type
type User = {
name: string
age: number
}
Ces deux déclarations sont équivalentes. Alors pourquoi avoir les deux ?
Ce que interface fait de spécial
Declaration merging
Il est possible de redéclarer une interface et TypeScript fusionnera les deux, c’est ce qu’on appelle le “declaration merging” :
interface User { name: string }
interface User { age: number }
const u: User = { name: "Alice", age: 30 }
// TypeScript fusionne les deux interfaces en une seule :
// { name: string; age: number }
Avec type, TypeScript lèverait une erreur immédiate car le nom est déjà pris.
Le declaration merging est utile pour étendre des types externes sans toucher au code source, mais dans son propre code c’est rarement ce que l’on veut.
Deux déclarations éparpillées qui se mergent silencieusement, c’est une source de bugs subtils.
Héritage explicite avec extends
interface Animal {
name: string
}
interface Dog extends Animal {
breed: string
}
type peut faire de même avec une intersection (&), mais les deux ne se comportent pas exactement de la même façon en cas de conflit de propriétés :
interface A { x: string }
interface B extends A { x: number }
// Erreur immédiate à la définition
type A = { x: string }
type B = A & { x: number }
// Pas d'erreur à la définition,
// mais x devient `never`
const b: B = { x: "hello" }
// Erreur : string n'est pas assignable à never
const b2: B = { x: 42 }
// Erreur : number n'est pas assignable à never
// Il est impossible de créer un objet valide de type B
extends détecte le conflit dès la définition. L’intersection l’accepte silencieusement et produit un type never pour la propriété en conflit ce qui rend le type inutilisable en pratique, sans avertissement au moment de la déclaration.
Ce que type fait de spécial
Unions
C’est le cas le plus courant où interface ne peut tout simplement pas aider.
Union de types primitifs, accepter plusieurs types de valeurs :
type ID = string | number
function getUser(id: ID) {
console.log(id) // accepte "abc" ou 42
}
Union de valeurs littérales, restreindre à un ensemble fixe de valeurs :
type Status = "active" | "banned" | "pending"
function updateStatus(s: Status) {
console.log(s)
// accepte uniquement ces 3 valeurs
}
Union d’objets avec narrowing, TypeScript comprend la distinction et va guider à la compilation :
type ApiResponse =
| { status: "success"; data: User }
| { status: "error"; message: string }
function handle(res: ApiResponse) {
if (res.status === "success") {
console.log(res.data)
// TypeScript sait que data existe
} else {
console.log(res.message)
// TypeScript sait que message existe
}
}
Types conditionnels
La syntaxe est T extends U ? TypeSiVrai : TypeSiFaux.
Ici extends ne signifie pas héritage, il signifie “est assignable à” ou “compatible avec”.
// Si T est une string, on renvoie le type string, sinon number
type Stringify<T> = T extends string ? string : number
type A = Stringify<"hello"> // string
type B = Stringify<boolean> // number
Mapped types
Transformer toutes les propriétés d’un type dynamiquement :
type Optionnel<T> = { [K in keyof T]?: T[K] }
type EnReadonly<T> = { readonly [K in keyof T]: T[K] }
C’est d’ailleurs le principe utilisé par Partial<T> et Readonly<T> dans la librairie standard de TypeScript.
Tuples et primitives
type Coordonnée = [number, number]
type Timestamp = number
// alias de primitif, impossible avec interface
La question pratique : lequel choisir ?
Il y a deux écoles.
- La recommandation du handbook TypeScript : préférer
interfacepar défaut, utilisertypequandinterfacene suffit pas. Le Handbook formule ça ainsi : “If you would like a heuristic, useinterfaceuntil you need to use features fromtype.” (Traduction : “Si vous souhaitez une heuristique, utilisezinterfacejusqu’à ce que vous ayez besoin des fonctionnalités detype.”) (source). - La recommandation pratique notamment celle de Matt Pocock (Total TypeScript) : préférer
typepar défaut, utiliserinterfaceuniquement pour l’héritage d’objets. Le raisonnement : le declaration merging est une surprise désagréable quand on ne s’y attend pas, ettypeest plus expressif. (source).
Dans les deux cas, la cohérence est de mise. À situation égale, il est plus important de choisir une convention et de s’y tenir que de débattre sans fin sur les mérites relatifs des deux. Le choix entre les deux est moins important que de ne pas mélanger les deux sans raison et à situation égale.
Résumé
| Besoin | Utiliser |
|---|---|
| Forme d’un objet simple | interface ou type, au choix |
| Héritage entre objets | interface avec extends |
Union (A | B) | type obligatoire |
| Type conditionnel, mapped type | type obligatoire |
| Tuple, alias de primitif | type obligatoire |
| Étendre un type externe (lib tierce) | interface pour le merging |
Cet article ne couvre pas le cas des classes. Dans un contexte orienté objet, interface s’impose plus naturellement : c’est elle qui modélise les contrats qu’une classe implémente et les hiérarchies d’héritage. Des frameworks reposent massivement sur ce modèle. La distinction présentée ici concerne les types d’objets littéraux, qui est le cas d’usage auquel on est confronté indépendamment du paradigme choisi.