Сохраняйте Type Guard действительными после рефакторинга в TypeScript

TypeScript — отличный язык для написания приложений с проверками безопасности типов. Расширение или рефакторинг кода в TypeScript намного проще, чем в обычном JavaScript.
В TypeScript есть хороший встроенный функционал для сужения интерфейса — Type Guards. Но они не всегда защищают от ошибок при расширении/рефакторинге кода, особенно если у вас большой проект.
Описание проблемы
Например, у нас есть магазин чая. Мы продаем два вида чая: рассыпной и пакетированный. Мы показываем все товары в одном списке. Каждый вид товара имеет гуманизированную ссылку, а в названии товара мы хотим указать количество граммов или количество пакетиков чая в пачке.
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");
};
Решение проблемы
Проблема с нашей Type Guard в том, что внутри мы проверяем наличие поля в объекте. Это операция JavaScript, и она никак не типизирована.
Что ж, давайте разберемся с этим. Я предлагаю использовать фабричную функцию для создания функций Type Guard на основе полей модели данных.
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")