@VinceOPS

TypeScript : Typage et Généricité

TS Generics

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>.

cute-cat

🌟 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 😁.

weak-tools

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


VinceOPS

Retrouvez-moi sur Twitter 🤷
@VinceOPS