Composed

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

TypeScript : type vs interface
Emmanuel LASTRA

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 interface par défaut, utiliser type quand interface ne suffit pas. Le Handbook formule ça ainsi : “If you would like a heuristic, use interface until you need to use features from type.” (Traduction : “Si vous souhaitez une heuristique, utilisez interface jusqu’à ce que vous ayez besoin des fonctionnalités de type.”) (source).
  • La recommandation pratique notamment celle de Matt Pocock (Total TypeScript) : préférer type par défaut, utiliser interface uniquement pour l’héritage d’objets. Le raisonnement : le declaration merging est une surprise désagréable quand on ne s’y attend pas, et type est 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é

BesoinUtiliser
Forme d’un objet simpleinterface ou type, au choix
Héritage entre objetsinterface avec extends
Union (A | B)type obligatoire
Type conditionnel, mapped typetype obligatoire
Tuple, alias de primitiftype 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.