Exploiter les Discriminated Union en Typescript

Denis SOURON - Oct 6 '22 - - Dev Community

De quoi parle-t-on ?

En Typescript, il est possible de définir des types de plusieurs manières : interface, classe, enum, mot-clé type, as const, etc. Dans cet article, nous allons nous concentrer sur les types construits à partir d'une union disjointe et les avantages d'une telle pratique. L'union en Typescript se fait via le symbole | (ex : type Union = A | B | C). Le terme disjoint n'est pas anodin car contrairement au polymorphisme, les types que l'on va utiliser peuvent ne rien avoir en commun.

Mise en situation

Prenons un exemple très simple, la représentation des utilisateurs dans une application. Ces utilisateurs peuvent être des invités (guest), des clients (customer) ou des administrateurs (admin). Les utilisateurs connectés ont un identifiant et les administrateurs ont des permissions spécifiques relatives à leur domaine d'administration. Construisons donc une interface User pour gérer nos utilisateurs.

interface User {
  userType: "Guest" | "Customer" | "Admin";
  login?: string;
  accessRights?: string[]; // could be more specific
}
Enter fullscreen mode Exit fullscreen mode

Certaines propriétés sont optionnelles car elles n'existent pas pour l'utilisateur invité (et qu'on utilise le mode strict du compilateur Typescript).

Cette interface nous permet de créer des utilisateurs valides :

const johnDoe: User = {
  userType: "Admin",
  login: "JohnDoe",
  accessRights: ["database", "monitoring"],
};
const guest: User = {
  userType: "Guest",
};
Enter fullscreen mode Exit fullscreen mode

Mais nous pouvons également créer des utilisateurs qui correspondent à des cas non souhaités :

const customerWithoutLogin: User = {
  userType: "Customer",
};
const guestWithAccessRights: User = {
  userType: "Guest",
  accessRights: ["fs"],
};
Enter fullscreen mode Exit fullscreen mode

Et on ne peut pas garantir si par exemple l'identifiant existe ou pas :

johnDoe.login.toUpperCase(); // Object is possibly 'undefined'.
guest.login.toUpperCase(); // Object is possibly 'undefined'.
customerWithoutLogin.login.toUpperCase(); // Object is possibly 'undefined'.
guestWithAccessRights.login.toUpperCase(); // Object is possibly 'undefined'.
Enter fullscreen mode Exit fullscreen mode

En général on finit par utiliser une condition ou du chainage optionnel, ce qui renforce l'incertitude sur le fonctionnement au runtime :

// Which one is really executed ?
johnDoe.login?.toUpperCase();
guest.login?.toUpperCase();
customerWithoutLogin.login?.toUpperCase();
guestWithAccessRights.login?.toUpperCase();
Enter fullscreen mode Exit fullscreen mode

Pour réduire cette incertitude, il ne nous reste plus qu'à écrire des tests unitaires, faire du monitoring, du debug et lever des exceptions. Heureusement, nous pouvons éviter tout ça avec un meilleur typage.

Discriminated Union

Explicit is better than implicit

Nous avons trois types d'utilisateurs distincts et les regrouper dans une même interface/classe est une erreur commune. Et c'est normal, on nous répète souvent DRY (Don't Repeat Yourself) et on a envie de factoriser les utilisateurs dans une même classe ou dans une même interface pour y appliquer des méthodes communes.

Et si on faisait le contraire ? Trois types d'utilisateurs, donc trois interfaces.

interface GuestUser {
  userType: "Guest";
}

interface CustomerUser {
  userType: "Customer";
  login: string;
}

interface AdminUser {
  userType: "Admin";
  login: string;
  accessRights: string[];
}
Enter fullscreen mode Exit fullscreen mode

Ensuite, il nous suffit de définir un type qui correspond à l'union des trois interfaces distinctes :

type User = GuestUser | CustomerUser | AdminUser;
Enter fullscreen mode Exit fullscreen mode

Cette fois, la syntaxe nous permet toujours de créer des utilisateurs valides :

const johnDoe: User = {
  userType: "Admin",
  login: "JohnDoe",
  accessRights: ["database", "monitoring"],
};
const customer: User = {
  userType: "Customer",
  login: "JaneDoe",
};
const guest: User = {
  userType: "Guest",
};
Enter fullscreen mode Exit fullscreen mode

Mais interdit la création d'utilisateurs qui n'ont pas de sens :

/**
 * Type '{ userType: "Customer"; }' is not assignable to type 'User'.
 * Property 'login' is missing in type '{ userType: "Customer"; }'
 * but required in type 'CustomerUser'.
 */
const customerWithoutLogin: User = {
  userType: "Customer",
};

/**
 * Type '{ userType: "Guest"; accessRights: string[]; }'is not assignable to type 'User'.
 * Object literal may only specify known properties,
 * and 'accessRights' does not exist in type 'GuestUser'.
 */
const guestWithAccessRights: User = {
  userType: "Guest",
  accessRights: ["fs"],
};
Enter fullscreen mode Exit fullscreen mode

L'accès aux propriétés est également bien plus prédictible :

johnDoe.login.toUpperCase(); // OK
customer.login.toUpperCase(); // OK
guest.login.toUpperCase(); // Property 'login' does not exist on type 'GuestUser'.
Enter fullscreen mode Exit fullscreen mode

L'inférence de type fait également des merveilles :

// login is defined because GuestUser is excluded (Type guard)
const displayLogin = (user: User) =>
  user.userType === "Guest" ? "Guest" : user.login;
Enter fullscreen mode Exit fullscreen mode

ProTip : Si l'inférence ne fonctionne pas, pensez à définir un champ qui va aider Typescript à déterminer le bon type (userType dans notre exemple). Vous pouvez aussi identifier le type manuellement avec le mot-clé is.

const isAdmin = (user: User): user is AdminUser =>
  (user as AdminUser).accessRights !== undefined;

const users: User[] = [johnDoe, customer, guest];

users.filter(isAdmin).forEach((admin) => console.log(admin.accessRights));
Enter fullscreen mode Exit fullscreen mode

Conclusion

Vous pouvez maintenant être plus précis sur le typage des données. Rien de révolutionnaire ici mais rappelez-vous que le typage est un bon moyen d'augmenter la prédictibilité de votre code.

Pour aller plus loin

Je vous invite à aller voir mon article sur le pattern matching en JS (qui arrive bientôt) qui complète assez bien les unions disjointes que l'on vient de voir. En combinant les deux, vous pouvez notamment faire une sorte de polymorphisme sans héritage et sans classe.

const redirectToHomePage = () => (location.href = "/");
const redirectToAccountPage = () => (location.href = "/account");
const redirectToAdminDashboardPage = () => (location.href = "/admin/dashboard");

export const redirectToUserPage = (user: User) =>
  ({
    Guest: () => redirectToHomePage(),
    Customer: () => redirectToAccountPage(),
    Admin: () => redirectToAdminDashboardPage(),
  }[user.userType]());
Enter fullscreen mode Exit fullscreen mode

Un article également disponible sur :

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player