L’année dernière nous avons démarré une nouvelle application nommée Timmi Office. Cette solution a pour objectif de permettre de savoir qui travaille où et de simplifier l’application de sa charte de télétravail.

Je profite de ce sujet pour vous parler d’une fonctionnalité pas si connue de Typescript : les type guards.

Modèle initial

Pour savoir si un utilisateur travaille depuis chez lui, depuis son bureau ou s’il est en déplacement, nous avons un concept de Localisation (WorkLocation).

Le modèle de base était très simple :

const enum WorkLocationType {
  RemoteWork = 'RemoteWork',
  Office = 'Office',
  OnTheGo = 'OnTheGo',
}

interface IWorkLocation {
  id: number,
  name: string,
  type: WorkLocationType,
}

Besoin : la gestion des bureaux

Seulement voilà, au bout de quelques sprints, nous avons voulu ajouter une gestion plus poussée des bureaux. Il nous fallait un moyen de rajouter la notion de capacité et de responsable de sites.

On aurait pu modifier l’interface précédente :

interface IWorkLocation {
  id: number,
  name: string,
  type: WorkLocationType,
  office?: IOffice,
}

interface IOffice {
  capacity: number;
  managerIds: number[];
}

Seulement, cette représentation ne permet pas de corréler la présence du champ office avec le type WorkLocationType.Office. Ainsi, les objets suivants ne seraient pas interdits par le compilateur :

const wrongRemoteWorkLocation: IWorkLocation = {
  id: 1,
  name: 'An office in my house?',
  type: WorkLocationType.RemoteWork,
  office: {
    capacity: 1000,
    managerIds: [1, 2, 3],
  },
};

const wrongOfficeWorkLocation: IWorkLocation = {
  id: 2,
  name: 'Where is my office?',
  type: WorkLocationType.Office,
}

Comment empêcher ça ? Les types unions à la rescousse !

interface IWorkLocation {
  id: number,
  name: string,
  type: WorkLocationType,
}

interface IOfficeWorkLocation extends IWorkLocation {
  type: WorkLocationType.Office,
  office: IOffice,
}

interface IOtherWorkLocation extends IWorkLocation {
  type: Exclude<WorkLocationType, WorkLocationType.Office>,
}

type WorkLocation = IOfficeWorkLocation | IOtherWorkLocation;

Avec cette modélisation, les exemples précédents sont bien en erreur :

const wrongRemoteWorkLocation: IWorkLocation = {
  id: 1,
  name: 'An office in my house?',
  type: WorkLocationType.RemoteWork,
  office: {
//~~~~~~~~~ Object literal may only specify known properties, and 'office' does not exist in type 'IOtherWorkLocation'
    capacity: 1000,
//  ~~~~~~~~~~~~~~
    managerIds: [1, 2, 3],
//  ~~~~~~~~~~~~~~~~~~~~~
  },
//~
};

const wrongOfficeWorkLocation: IWorkLocation = {
//    ~~~~~~~~~~~~~~~~~~~~~~~ Property 'office' is missing in type '{...}' but required in type 'IOfficeWorkLocation'.
  id: 2,
  name: 'Where is my office?',
  type: WorkLocationType.Office,
}

Besoin : affichage d’un label dépendant du type

Besoin suivant, je veux afficher une liste de label qui contient le nom des localisations. Dans le cas des localisations de type “Bureau”, je veux également ajouter la capacité.

Premier essai :

function getLabel(wl: WorkLocation) {
  let label = wl.name;

  if (wl.office) {
//       ~~~~~~ Property 'office' does not exist on type 'WorkLocation'.
    label += ` (capacity: ${wl.office.capacity})`;
//                             ~~~~~~ Property 'office' does not exist on type 'WorkLocation'.
  }

  return label;
}

Tant qu’on ne fait pas de vérification sur le type de la WorkLocation, on ne peut accéder qu’aux propriétés communes entre IOfficeWorkLocation et IOtherWorkLocation: id, name et type.

Comment accéder à la propriété office ? On peut restreindre l’union WorkLocation. Avec la bonne condition sur type, TypeScript est capable de savoir si notre localisation est un IOfficeWorkLocation ou un IOtherWorkLocation.

function getLabel(wl: WorkLocation) {
  let label = wl.name;
  //          ^? (parameter) wl: WorkLocation

  if (wl.type === WorkLocationType.Office) {
    label += ` (capacity: ${wl.office.capacity})`;
    //                      ^? (parameter) wl: IOfficeWorkLocation
  }

  return label;
}

Besoin : des statistiques sur les bureaux

Le besoin suivant est de connaître, d’une part le nombre de bureaux et d’autre part la capacité cumulée des bureaux.

Premier essai :

const allWorkLocations: WorkLocation[] = [/* ... */];
const allOfficeWorkLocations = allWorkLocations.filter(wl => wl.type === WorkLocationType.Office);

const officesCount = allOfficeWorkLocations.length;

const officesCapacity = allOfficeWorkLocations
  .reduce((sum, wl) => sum + wl.office.capacity, 0);
//                              ~~~~~~ Property 'office' does not exist on type 'WorkLocation'.

Encore le même refrain : impossible d’accéder à la propriété office.

const allWorkLocations: WorkLocation[] = [/* ... */];
const allOfficeWorkLocations = allWorkLocations.filter(wl => wl.type === WorkLocationType.Office);
//    ^? const allOfficeWorkLocations: WorkLocation[]

Malgré le filtre, le type de allOfficeWorkLocations est toujours WorkLocation[]. Pourquoi ? Car par défaut, la signature de Array.prototype.filter utilisée est :

filter(predicate: (value: T, index: number, array: T[]) => unknown, thisArg?: any): T[];

Cette signature ne change pas le type de retour ! Il existe une deuxième signature qui nous intéresse plus :

filter<S extends T>(predicate: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];
//                                                                      ^^^^^^^^^^

Cette signature nous permet d’avoir un sous-type en sortie. Voici comment la mettre en place :

function isOffice(wl: WorkLocation): wl is IOfficeWorkLocation {
  return wl.type === WorkLocationType.Office;
}

const allWorkLocations: WorkLocation[] = [/* ... */];
const allOfficeWorkLocations = allWorkLocations.filter(isOffice);
//    ^? const allOfficeWorkLocations: IOfficeWorkLocation[]

La variable allOfficeWorkLocations a le bon type ! On peut désormais faire nos calculs :

const officesCount = allOfficeWorkLocations.length;

const officesCapacity = allOfficeWorkLocations
  .reduce((sum, wl) => sum + wl.office.capacity, 0);

Note : on peut aussi modifier notre fonction getLabel pour utiliser isOffice :

function getLabel(wl: WorkLocation) {
  let label = wl.name;

  if (isOffice(wl)) {
    label += ` (capacity: ${wl.office.capacity})`;
  }

  return label;
}

Besoin : Gestion d’un champ nullable

Dans les nouveaux projets, nous activons le mode strict de Typescript. Ce mode nous force à identifier les valeurs qui peuvent être nulles et à donner une valeur par défaut aux propriétés d’une classe.

Je ne peux donc pas écrire ça :

class MyComponent {
  public officeWorkLocations: IOfficeWorkLocation[];
  //     ~~~~~~~~~~~~~~~~~~~
  //     Property 'officeWorkLocations' has no initializer and is not definitely assigned in the constructor.
}

Pour résoudre ce problème, je peux donner une valeur initiale à cette propriété :

class MyComponent {
  public officeWorkLocations: IOfficeWorkLocation[] = [];
  //                                               ^^^^^
}

Je peux aussi marquer cette propriété comme facultative :

class MyComponent {
  public officeWorkLocations?: IOfficeWorkLocation[];
  //                        ^
}

Une troisième option consiste à rassurer le compilateur en lui disant que tout va bien, qu’il peut regarder ailleurs avec :

class MyComponent {
  public officeWorkLocations!: IOfficeWorkLocation[];
  //                        ^
}

Cette solution est moins sûre (comme tout contournement du compilateur comme un XX as any). Eslint a une règle pour détecter cette pratique.

J’opte pour la solution à base de propriété facultative. Le problème est qu’il faut gérer le cas undefined lors de son utilisation !

class MyComponent {
  public officeWorkLocations?: IOfficeWorkLocation[];

  public loadOffices(): void {
    this.officeWorkLocations = [/* ...  */];
  }

  public logTotalCapacity(): void {
    const capacity = this.officeWorkLocations.reduce((sum, wl) => sum + wl.office.capacity, 0);
    //               ~~~~~~~~~~~~~~~~~~~~~~~~
    //               Error: Object is possibly 'undefined'.
    console.log({ capacity });
  }
}

const component = new MyComponent();
component.loadOffices();
component.logTotalCapacity();

Plusieurs options nous permettent de résoudre ce problème :

  • on peut décider de renvoyer undefined si officeWorkLocations vaut undefined

     class MyComponent {
        public logTotalCapacity(): void {
           const capacity = this.officeWorkLocations?.reduce((sum, wl) => sum + wl.office.capacity, 0);
           //                                       ^
           console.log({ capacity });
        }
     }
    
  • on peut décider de renvoyer 0 si officeWorkLocations vaut undefined

     class MyComponent {
        public logTotalCapacity(): void {
           const capacity = this.officeWorkLocations?.reduce((sum, wl) => sum + wl.office.capacity, 0) ?? 0;
           //                                       ^                                                 ^^^^^
           console.log({ capacity });
        }
      }
    
  • on peut renvoyer une erreur si officeWorkLocations vaut undefined

     class MyComponent {
        public logTotalCapacity(): void {
           if (this.officeWorkLocations === undefined) {
              throw new Error('Unexpected undefined value.');
           }
           const capacity = this.officeWorkLocations.reduce((sum, wl) => sum + wl.office.capacity, 0);
           console.log({ capacity });
        }
      }
    

    Suite au throw, le compilateur sait que officeWorkLocations n’est pas undefined;

Dans notre exemple, la méthode logTotalCapacity est appelée après loadOffices. Je choisis la 3e option : je renvoie une erreur.

Devoir écrire ce if de 3 lignes et le dupliquer dans chaque méthode de mon composant ne m’enchante guère. Pas de soucis, il existe un deuxième type de type guard !

function assertNotNil<T>(input: T): asserts input is NonNullable<T> {
  if (input === null || input === undefined) {
    throw new Error('[assertNotNull] Unexpected null or undefined value.');
  }
}

Le type NonNullable est dans les types de base Typescript.

Avec cette fonction, le code est plus lisible est plus concis :

assertNotNil(this.officeWorkLocations);
const capacity = this.officeWorkLocations.reduce((sum, wl) => sum + wl.office.capacity, 0);

Tout comme dans l’exemple avec le if et le throw, le compilateur sait qu’après le assertNotNil, officeWorkLocations n’est pas undefined.

La trousse à outils

Voici quatre fonctions que j’utilise dans tous mes projets :

function isNil<T>(input: T | null | undefined): input is null | undefined {
  return input === null || input === undefined;
}

function isNotNil<T>(input: T): input is NonNullable<T> {
  return !isNil(input);
}

function assertNotNil<T>(input: T): asserts input is NonNullable<T> {
  if (!isNotNil(input)) {
    throw new Error('[assertNotNil] Unexpected null or undefined value.');
  }
}

Et voici des exemples d’utilisation :

const fruits = ['banana', null, 'apple', undefined, 'strawberry'];
//    ^? const fruits: (string | null | undefined)[]

const nilValues = fruits.filter(isNil); // [null, undefined]
const values = fruits.filter(isNotNil); // ['banana', 'apple', 'strawberry']
//    ^? const values: string[]

const banana = fruits[0];
//    ^? const banana: string | null | undefined
assertNotNil(banana);

console.log(banana);
//          ^? const banana: string

Bonus : utilisation dans un contexte Angular

Les types guards sont aussi utiles dans les templates des composants Angular.

Voici un exemple d’utilisation avec les mêmes modèles que précédemment :

@Component({
  selector: 'app-example',
  templateUrl: './example.component.html',
  styleUrls: ['./example.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExampleComponent {
  public workLocation!: WorkLocation;
  public WorkLocationType = WorkLocationType;

  public isOfficeWorkLocation(wl: WorkLocation): wl is IOfficeWorkLocation {
    return wl.type === WorkLocationType.Office;
  }
}

Et le template associé :

<div>{{ workLocation.office }}</div>
<!--                 ~~~~~~ Error: Property 'office' does not exist on type 'WorkLocation'. -->

<div *ngIf="workLocation.type === WorkLocationType.Office">
  {{ workLocation.office }}
</div>

<div *ngIf="isOfficeWorkLocation(workLocation)">
  {{ workLocation.office.capacity }}
</div>

On voit que le ngIf nous apporte les mêmes fonctionnalités que le if standard : à l’intérieur du nœud, workLocation a bien le type IOfficeWorkLocation.

Pour des raisons de performance, il est conseillé de ne pas faire d’appels de fonctions dans les templates : ces fonctions sont appelées à chaque cycle de détection de changement d’Angular. Ces cycles ont lieu très souvent ! La solution préconisée est d’utiliser un Pipe Angular. En effet, les Pipes ne sont appelés que si leurs paramètres changent, ce qui arrive déjà bien moins souvent.

Voici un Pipe qui permet d’appeler un type guard dans le template :

@Pipe({
  name: 'appTypeGuard',
})
export class TypeGuardPipe implements PipeTransform {
  public transform<T, U extends T>(obj: T, typeGuardFn: (arg: T) => arg is U): obj is U {
    return typeGuardFn(obj);
  }
}

Et son utilisation :

<div *ngIf="workLocation | appTypeGuard: isOfficeWorkLocation">
  {{ workLocation.office.capacity }}
</div>

Dans ce cas, la méthode isOfficeWorkLocation n’est appelée que si workLocation est mise à jour.

Mise en garde

Lorsqu’on utilise les fonctions asserts X is Y et X is Y, le compilateur nous fait aveuglément confiance. Dans le cas suivant, tout compile parfaitement, mais des erreurs auront lieu au lancement du script :

function isOfficeWorkLocation(wl: WorkLocation): wl is IOfficeWorkLocation {
  return true;
}

const workLocation: WorkLocation = {
  id: 5,
  name: 'Télétravail',
  type: WorkLocationType.RemoteWork,
};

if (isOfficeWorkLocation(workLocation)) {
  console.log(workLocation.office.capacity);
  // TypeError: Cannot read properties of undefined (reading 'capacity')
}

Le code précédent ne causera des erreurs qu’au lancement et non à la compilation.

Il faut donc utiliser les types guards avec parcimonie et être très vigilant sur leur implémentation.

Conclusion

Typescript nous offre tout un panel d’outils pour nous aider à créer des types qui représentent au plus proche la réalité. Les types unions nous permettent de décrire l’ensemble des possibilités et empêchent donc de pouvoir créer des objets incohérents. De plus, les types unions permettent d’éviter d’avoir une unique interface avec l’ensemble des champs en facultatif.

Ensuite, les fonctions qui renvoient un asserts myArgument is MyType et myArgument is MyType permettent de créer des petites fonctions utilitaires qui simplifient les validations de type permettent de travailler encore plus facilement avec des types unions.

Ressources