Gardez Type Guards valide après la refactorisation dans TypeScript

Dec 04 2022
Les gardes de type génériques aident à refactoriser le code avec moins de bogues
TypeScript est un excellent langage pour écrire des applications avec des contrôles de sécurité de type. L'extension ou la refactorisation du code dans TypeScript est beaucoup plus facile qu'en JavaScript ordinaire.

TypeScript est un excellent langage pour écrire des applications avec des contrôles de sécurité de type. L'extension ou la refactorisation du code dans TypeScript est beaucoup plus facile qu'en JavaScript ordinaire.

TypeScript a une fonctionnalité intégrée intéressante pour le rétrécissement de l'interface - Type Guards. Mais ils ne protègent pas toujours contre les erreurs lors de l'extension/refactorisation du code, surtout si vous avez un gros projet.

Description du problème

Par exemple, nous avons un magasin de thé. Nous vendons deux sortes de thé : en vrac et en sachet. Nous affichons tous les produits dans une seule liste. Chaque type de produit a un lien humanisé, et dans le nom du produit, nous voulons spécifier le nombre de grammes ou le nombre de sachets de thé dans un paquet.

import React from "react";

type Tea = { id: number; name: string; price: number };

type LooseTea = Tea & { weight: number };
type BaggedTea = Tea & { size: number };

const isLooseTea = (x: Tea): x is LooseTea => "weight" in x;
const isBaggedTea = (x: Tea): x is BaggedTea => "size" in x;

const getLink = (x: Tea): string => {
  if (isLooseTea(x)) return `/loose-tea/${x.id}`;
  if (isBaggedTea(x)) return `/bagged-tea/${x.id}`;
  throw new Error("Unknown tea");
};

const getTitle = (x: Tea): string => {
  if (isLooseTea(x)) return `${x.name} / ${x.weight}g`;
  if (isBaggedTea(x)) return `${x.name} / ${x.size} teabags`;
  throw new Error("Unknown tea");
};

const TeaItems: React.FC<{ items: Tea[] }> = ({ items }) => {
  return (
    <ul>
      {items.map((x) => (
        <li key={x.id}>
          <a href={getLink(x)}>{getTitle(x)}</a>
        </li>
      ))}
    </ul>
  );
};

type BaggedTea = Tea & { bags: number; weightPerBag: number };

// before:
const getTitle = (x: Tea): string => {
  if (isLooseTea(x)) return `${x.name} / ${x.weight}g`;
  // Property 'size' does not exist on type 'BaggedTea'. ts(2339)
  if (isBaggedTea(x)) return `${x.name} / ${x.size} teabags`;
  throw new Error("Unknown tea");
};

// after:
const getTitle = (x: Tea): string => {
  if (isLooseTea(x)) return `${x.name} / ${x.weight}g`;
  if (isBaggedTea(x)) return `${x.name} / ${x.bags} teabags ~ ${x.weightPerBag}`;
  throw new Error("Unknown tea");
};

Solution du problème

Le problème avec notre Type Guard est qu'en interne nous vérifions la présence d'un champ dans l'objet. Il s'agit d'une opération JavaScript et elle n'est en aucun cas typée.

Eh bien, gérons cela. Je propose d'utiliser une fonction d'usine pour créer des fonctions Type Guard basées sur les champs du modèle de données.

type AnyObject = Record<string, any>;

// https://stackoverflow.com/a/52991061
type RequiredKeys<T> = {
  [K in keyof T]-?: AnyObject extends Pick<T, K> ? never : K
}[keyof T];

const createShapeGuard = <T extends AnyObject>(...keys: RequiredKeys<T>[]) => {
  return (obj: unknown): obj is T => {
    if (typeof obj !== "object" || obj === null) return false;

    for (const key of (keys as string[])) {
      if (!(key in obj)) return false;
    }

    return true;
  };
};

const isLooseTea = createShapeGuard<LooseTea>("weight")
const isBaggedTea = createShapeGuard<BaggedTea>("bags", "weightPerBag")

type BaggedTea = Tea & { bags: number; bagWeight: number };

// Argument of type '"weightPerBag"' is not assignable to parameter of type 'RequiredKeys<BaggedTea>'.ts(2345)
const isBaggedTea = createShapeGuard<BaggedTea>("bags", "weightPerBag")