TypeScript : Typage et Généricité

Avant tout, une bonne et heureuse année 2019 à tous (c'est certes tardif, mais le cœur y est !). Que vos codebases soient lisibles et testées, vos stacks rafraîchissantes, vos projets intéressants 🤗.


La généricité permet d'écrire des définitions (de classes, interfaces, fonctions, types...) paramétriques. On appelle ces définitions des Génériques. Présents sous le même nom dans Java (1.5), C# (2), ils existent dans TypeScript depuis sa création (merci qui ? Merci Anders !).

Un exemple très commun en TypeScript est celui des tableaux, avec Array<T>. T est appelé "paramètres de type" (type parameter, à ne pas confondre avec un type de paramètre 🙃).

const items: Array<number> = [1, 2, 3];  
items.push(4); // ok  
items.push('a'); // erreur: argument of type 'a' is not assignable to parameter of type 'number'

// "n" est déduit comme étant de type 'number'
const results = items.map(n => n.toFixed(1)); // ["1.0", "2.0", "3.0", "4.0"]  

Les mêmes méthodes (push, map, etc) et la même analyse statique existent avec Array<string>, Array<Date>, etc. Array est dit "type générique".

💡 C'est aussi vrai pour Map<K, V>, Set<T>, Promise<T>, Observable<T> (RxJS), etc.

Show time 🎉

Certains exemples sont volontairement simplistes, l'objectif étant de lever toute ambiguïté en se concentrant sur les sujets de la généricité et du typage 🤗

Grâce aux generics, il est possible de :

🌟 Déclarer des contraintes génériques

On définit une fonction générique pick type-safe permettant de récupérer un extrait d'objet, ici, à l'aide de K extends keyof T, dit "K contraint par T".

function pick<T, K extends keyof T>(source: T, ...keys: K[]): Partial<T> {  
  const result: Partial<T> = {};
  keys.forEach(key => result[key] = source[key]);
  return result;
}

const user = { weight: 55, name: 'Winry', birthDate: new Date('1985-06-13') };  
console.log(pick(user, 'name', 'weight')); // { name: 'Winry', weight: 55 }  

Bien sûr, tout est analysé/déduit par le compilateur tsc et l'IDE sait aussi faire l'auto-complétion des propriétés saisies (name et weight) 🔥.

🌟 Obtenir les propriétés d'un certain type d'une classe ou interface

En utilisant les types conditionnels (conditional types), qui prennent la forme d'une expression ternaire utilisant extends :

type StringProperty<T> = { [P in keyof T]: T[P] extends string ? P : never }[keyof T];

class User {  
  birthDate: Date;
  isAdmin: boolean
  firstName: string;
  lastName: string;
}
type S1 = StringProperty<User> // 'firstName' | 'lastName'  

On peut rendre ce type générique ... encore plus générique !

// toutes les propriétés de type "A" du type "T"
type PropertyOfType<T, A> = { [P in keyof T]: T[P] extends A ? P : never }[keyof T];

// décliné en :
type StringProperty<T> = PropertyOfType<T, string>;  
type NumberProperty<T> = PropertyOfType<T, number>;  
type MethodProperty<T> = PropertyOfType<T, (...args: any[]) => any>;  
// ...

🌟 Définir des types élaborés (mapped types)

Rendus possibles grâce à la généricité, vous connaissez la plupart d'entre eux si vous utilisez TypeScript régulièrement :

  • Partial<T> : Type ayant toutes les propriétés de T, optionnelles (modificateur ?).
  • Required<T> : Type ayant toutes les propriétés de T, requises (modificateur -?).
  • Readonly<T> : Type ayant toutes les propriétés de T, non ré-assignables (modificateur readonly).
  • Pick<T, K extends keyof T> : Type ayant toutes les propriétés K de T.

Quelques autres qui, bien qu'absents des lib.*.d.ts, sont très communément utilisés dans les projets TypeScript:

// Type ayant toutes les propriétés de T, pouvant aussi être `null` 
type Nullable<T> = { [P in keyof T]: T[P] | null };

// Type ayant toutes les propriétés de T, sans modificateur `readonly`
type Writable<T> = { -readonly [P in keyof T]: T[P] };

// Type ayant toutes les propriétés de T sauf celles données pour K
// e.g.: Omit<User, 'firstName' | 'lastName'> 
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// `Partial<T>` supportant plusieurs niveaux de profondeur (sous-objets, sous-sous-objets, etc)
type DeepPartial<T> = {  
    [P in keyof T]?: T[P] extends Array<infer U>
    ? Array<DeepPartial<U>>
    : DeepPartial<T[P]>
};

💡 L'implémentation de DeepPartial ci-dessus est incomplète (pour des raisons de lisibilité) : elle doit aussi intégrer l'assignation des types Date (à conserver "tel quel") et ReadonlyArray<T>.

🌟 Introduire des variables de Type à déduire (infer)

L'introduction des types conditionnels (TypeScript 2.8), avec le mot-clé extends, a aussi introduit le mot-clé infer, qui permet de déclarer une variable de type déduit.
DeepPartial<T> en montre un premier exemple ci-dessus, en transformant les Array<U> en Array<DeepPartial<U>> (souhaité) et non en DeepPartial<Array<U>> (insensé).

Autres exemples :

// Type de retour d'une fonction T, introduit comme "R"
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

// Type déduit "U" d'une Promise, d'un Array, d'une fonction, ou T
type Unpacked<T> =  
    T extends (infer U)[] ? U :
    T extends (...args: any[]) => infer U ? U :
    T extends Promise<infer U> ? U :
    T;
type U1 = Unpacked<number[]>; // number  
type U2 = Unpacked<Promise<string>>; // string  
type U3 = Unpacked<Unpacked<Promise<string[]>>>; // string  
type U4 = Unpacked<Date>; // Date


// Type (tuple) contenant les types des paramètres d'une fonction
type Parameters<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never;  
// type T1 = Parameters<typeof Math.min> // [number[]]

// Type (tuple) contenant les types des paramètres d'un constructeur
type ConstructorParameters<T extends new (...args: any[]) => any> = T extends new (...args: infer P) => any ? P : never;  
// type T2 = ConstructorParameters<typeof Account>

🌟 Utiliser un "paramètre du reste" générique

Dans cet exemple, avec ...T[].
On définit un type ColorFilter attendant soit : une et une seule valeur de type Color, ou un opérateur 'AND' ou 'OR' suivi d'au moins deux valeurs de type Color.

type FilterAndOr<T, K = 'AND' | 'OR'> = [T] | [K, T, T, ...T[]];  
type Color = 'red' | 'green' | 'blue' | 'yellow' | 'white';

// Permet l'utilisation des valeurs:
// ["blue"]
// ["AND", "blue", "red"]
// ["OR", "yellow", "green", "red"]
// ["AND", "blue", "white", "red", "green"]
type ColorFilter = FilterAndOr<Color>;  

🌟 Etc, etc, etc

Il existe une infinité de cas d'usage des Generics et il m'est impossible d'en faire une liste exhaustive, cependant, comme tout bon outil, attention à ne pas tomber dans le piège habituel : quand on a un marteau, tout ressemble à un clou 😁.


Pour toujours plus de TypeScript 💕, je vous invite à regarder cette vidéo d'Anders Hejlsberg à la DotJS 2018 à Paris. Enjoy!