TypeScript

После долгих лет “постоянной” работы с JavaScript у меня (наконец-то) появилась возможность приобщиться к TypeScript. Несмотря на то, что некоторые знакомые смело уверяли меня, что выучить его будет делом 5 минут… Я так не думал. 

По большей части этот язык действительно быстро и легко учится. Но переход к новой парадигме всегда сопровождается сложными пограничными случаями. И в этой связи TypeScript не стал исключением. 

Мне уже приходилось биться над решением головоломок, чтобы в React/TS определить значения свойств по умолчанию при тех же условиях, при которых в React/JS все было понятно (и просто). Моя последняя проблема связана с обработкой ключей объектов.

Описание проблемы 

При работе с JavaScript мне часто приходится иметь дело с различными объектами. Если у вас был опыт разработки в JS, вы понимаете, что я или, к примеру, разработчик Java имеем разное представление об “объектах”. Большинство из тех, что мне встречались в JS, больше похожи на хеш-карты или, говоря на языке терминологии, кортежи

Например, для меня нет ничего необычного в том, что два объекта могут выглядеть следующим образом: 

const user1 = {
  name: 'Joe',
  city: 'New York',
  age: 40,
  isManagement: false,
};


const user2 = {
  name: 'Mary',
  city: 'New York',
  age: 35,
  isManagement: true,
};

Ничего сверх сложного, верно? Эти “объекты” просто … структуры данных. 

А теперь давайте представим, что у меня часто возникает необходимость определить, что общего есть у неких двух пользователей (при условии, что оно есть). Поскольку именно такого сравнительного анализа часто требует мое приложение, я захотел создать универсальную функцию, которая будет принимать два любых объекта и выявлять общие для них значения ключей. 

В JavaScript я мог бы быстро набросать небольшую практичную функцию наподобие следующей: 

const getEquivalentKeys = (object1: {}, object2 = {}) => {
   let equivalentKeys = [];
   Object.keys(object1).forEach(key => {
      if (object1[key] === object2[key]) {
         equivalentKeys.push(key);
      }
   });
   return equivalentKeys;
}

Примечание: я понимаю, что существует даже более продуктивный способ решения этой задачи, например при помощи хорошей функции .map(). Но на мой взгляд, этот вариант более наглядный (в смысле более содержательный).

При помощи вышеуказанной функции можно теперь сделать следующее:

console.log(getEquivalentKeys(user1, user2));
// логи: ['city']

И ее результат сообщает мне, что двух пользователей, user1 и user2, объединяет общий город проживания. Невероятно просто, да? 

А теперь преобразуем это в TypeScript: 

const getEquivalentKeys = (object1: object, object2: object: Array<string> => {
   let equivalentKeys = [] as Array<string>;
   Object.keys(object1).forEach((key: string) => {
      if (object1[key] === object2[key]) {
         equivalentKeys.push(key);
      }
   });
   return equivalentKeys;
}

Для меня все “выглядит” правильно, но… TS это не нравится. Если быть точным, то ему не по душе вот эта строка: 

if (object1[key] === object2[key]) {

TS сообщает: 

Элемент неявно содержит тип 'any', так как выражение типа 'string' не может быть использовано для индексации типа‘{}’.

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.

Однако… 

Конечно же, мне известно, что можно было бы легко использовать интерфейс для определения типа user и последующего объявления его в сигнатуре функции. Но мне бы хотелось, чтобы эта функция работала с любыми объектами. И я понимаю, почему жалуется TS, но мне это определенно не нравится. А жалуется он потому, что не знает какой тип должен индексировать обобщённый object.

Мучения с обобщенными типами 

Уже имея за плечами опыт разработок Java и C#, я сразу же подумал о том, что это как раз случай для обобщенных типов. Поэтому я попробовал следующий вариант: 

const getEquivalentKeys = <T1 extends object, T2 extends object>(object1: T1, object2: T2): Array<string> => {
   let equivalentKeys = [] as Array<string>;
   Object.keys(object1).forEach((key: string) => {
      if (object1[key] === object2[key]) {
         equivalentKeys.push(key);
      }
   });
   return equivalentKeys;
}

Но в результате — та же проблема, что и в предыдущем примере. TS все еще не понимает, что тип string может быть индексом для {}. И я знаю, почему он жалуется, потому что данный вариант 

const getEquivalentKeys = <T1 extends object, T2 extends object>
(object1: T1, object2: T2): Array<string> => {

… функционально эквивалентен вот этому: 

const getEquivalentKeys = (object1: object, object2: object):
 Array<string> => {

Поэтому я попробовал более явное приведение типов, как то: 

const getEquivalentKeys = <T1 extends object, T2 extends object>(object1: T1, object2: T2): Array<string> => {
   let equivalentKeys = [] as Array<string>;
   Object.keys(object1).forEach((key: string) => {
      const key1 = key as keyof T1;
      const key2 = key as keyof T2;
      if (object1[key1] === object2[key2]) {
         equivalentKeys.push(key);
      }
   });
   return equivalentKeys;
}

Теперь TS снова жалуется на строку: 

if (object1[key1] === object2[key2]) {

На этот раз он сообщает: 

Это условие будет всегда возвращать 'false', поскольку типы 'T1[keyof T1]' и 'T2[keyof T2]' не имеют совпадений.

И вот я замечаю, что кричу в монитор: 

Но у них же есть совпадение!!! 

К сожалению, мой монитор отвечает мне лишь молчаливым взглядом…

При этом, есть один дешевый и сердитый способ провернуть это дело: 

const getEquivalentKeys = <T1 extends any, T2 extends any>(object1: T1, object2: T2): Array<string> => {
   let equivalentKeys = [] as Array<string>;
   Object.keys(object1).forEach((key: string) => {
      if (object1[key] === object2[key]) {
         equivalentKeys.push(key);
      }
   });
   return equivalentKeys;
}

Вуаля! На этот раз никаких жалоб от Typescript, но зато у меня их накопилось достаточно. Дело в том, что приведение T1 и T2 к типу any на корню разрушает ту прекрасную магию, которую мы должны обретать с TS. Нет никакого смысла его использовать, если я стану создавать подобные функции, поскольку все может быть передано в getEquivalentKeys(), а TS так ничего и не поймет. 

Вернулись к тому, с чего начали … 

Мучения с интерфейсами 

Вообще, когда вам нужно напрямую сообщить TS о типе объекта, вы используете интерфейсы. В результате получается следующее:

interface GenericObject {
   [key: string]: any,
}
const getEquivalentKeys = (object1: GenericObject, object2: GenericObject): Array<string> => {
   let equivalentKeys = [] as Array<string>;
   Object.keys(object1).forEach((key: string) => {
      if (object1[key] === object2[key]) {
         equivalentKeys.push(key);
      }
   });
   return equivalentKeys;
}

И… это работает. Так как здесь он делает именно то, что мы от него и ожидаем. В функцию гарантированно будут переданы только объекты

Но честно вам признаюсь, как же сильно меня это раздражает. Возможно, пройдет время, и я не буду слишком сильно переживать по этому поводу. Но сейчас по какой-то причине меня действительно выводит из себя мысль, что мне приходится рассказывать TS, что для индексации object можно использовать string

Диалоги с компилятором 

Как раз в связи с этой темой приведу вам замечательный комментарий пользователя @miketalbot:

Я убежденный программист C#, и мне бы хотелось транслировать большую часть его кода в мир JS при помощи TypeScript. Но вы же не думаете, что я буду тратить драгоценное время моей жизни на то, чтобы объяснять компилятору свою совершенную логическую структуру. 

Отлично сказано, Майк. В самую точку. 

Почему же меня это беспокоит?? 

Знакомство с TS начинается с того, что это якобы расширенный набор JavaScript. Теперь я в полной мере понимаю, что если вы действительно хотите максимально использовать сильные стороны этого языка, то будет много “базового” кода JS, который не понравится компилятору TS. 

Но ссылка на значение объекта по ключу (ключу типа string) — это же такая простая, основная и важная часть JS, так что меня расстраивает необходимость создавать специальный интерфейс GenericObject только для того, чтобы объяснить компилятору следующее: 

Да, это правда… для индексации этого объекта можно использовать string. 

Я имею в виду, что это работает. Но если именно так я и должен это делать, то мне остается только сказать: 

Подождите… что???

Думаю, я был бы не менее раздражен, если бы вы попросили меня объяснить TS, что string может содержать и буквы, и числа, и специальный символы. 

Теперь, уже научившись находить окольные пути, думаю, что этот пример один из тех, к которым впоследствии “привыкаешь”. Или, …возможно, в TS существует простой способ, позволяющий мне найти выход из этой ситуации (без потери основных его преимуществ). Но если такое магическое решение и существует, то мои скромные навыки поиска в сети его еще не обнаружили.

Читайте также:


Перевод статьи Adam Nathaniel Davis: Key Headaches in TypeScript

Предыдущая статьяTextHero - самый простой способ чистки и анализа текста в Pandas
Следующая статьяСказание о шаблоне Стратегия и его реализации