Неочевидные моменты TypeScript и способы их решения

от автора

Приветствую! Меня зовут Андрей Степанов, я CTO во fuse8. Мне интересно знакомиться с опытом коллег по цеху и делиться своим. В сфере я уже больше 20 лет. В этой статье приведены примеры ситуаций, с которыми вы можете столкнуться, если работаете с TypeScript, но не знакомы с некоторыми тонкостями о них и поговорим. Огромная благодарность за составление и помощь в подготовке материала разработчикам Дмитрию Бердникову и Александру Инкееву!

Если вам будет удобно сразу же проверять каждый пример, читая статью, можно это сделать в редакторе. Он удобен тем, что версию TypeScript в нем можно переключать.

Получение any вместо unknown

Если используем тип any, то теряем типизацию — мы можем обратиться к любому методу или свойству такого объекта, и компилятор не предупредит нас о возможных ошибках. Если же мы используем unknown, то компилятор подскажет о них. 

Некоторые функции и операции возвращают any по умолчанию — это не совсем очевидно, вот несколько примеров:

// JSON.parse const a = JSON.parse('{ a: 1 }'); // any   // Array.isArray function parse(a: unknown) {   if (Array.isArray(a)) {     console.log(a); // a[any]   } }   // fetch fetch("/")   .then((res) => res.json())   .then((json) => {     console.log(json); // any   });   // localStorage, sessionStorage const b = localStorage.a; // any const c = sessionStorage.b // any

Эту проблему может решить ts-reset

ts-reset – это библиотека, которая помогает решить некоторые неочевидные моменты, когда хотелось бы, чтобы TypeScript по умолчанию работал иначе.

Методы массивов слишком строгие для конструкции as const

Также это встречается в методах has у Set и Map.

Пример: создаем массив пользователей, присваиваем конструкцию as const, затем вызываем метод includes и получаем ошибку, потому что аргумент 4 не существует в типе userIds.

const userIds = [1, 2, 3] as const;  userIds.includes(4);

Избавиться от ошибки также поможет использование ts-reset.

Отфильтровать массив от undefined

Предположим, у нас есть какой-то числовой массив, в котором может быть undefined. Чтобы избавиться от этих undefined, отфильтруем массив. Но массив newArr всё равно будет содержать тип массива number или undefined.

const arr = [1, 2, undefined]; const newArr = arr.filter((item) => item !== undefined);

Решить проблему можно следующим образом, тогда newArr2 будет иметь тип number:

const newArr2 = arr.filter((item): item is number => item !== undefined);

Также ts-reset может помочь, но только для случая, когда аргумент функции filter является типом BooleanConstructor.

const filteredArray = [1, 2, undefined].filter(Boolean)

Сужение типа с помощью скобочной нотации

Создаем объект с типом ключ строка, значение строка или массив строк. 

Затем обращаемся к свойству объекта, используя скобочную нотацию и проверяем, что тип возвращаемого значения объекта является строкой. В typescript версии ниже 4.7 тип queryCountry будет строкой или массивом строк, т.е. автоматическое сужение типов не работает, хотя мы уже проверили условием. 

Если же использовать typescript версии 4.7 и выше, сужение типа будет работать так, как мы этого ожидаем.

const query: Record<string, string | string[]> = {};  const COUNTRY_KEY = 'country';  if (typeof query[COUNTRY_KEY] === 'string') {     const queryCountry: string = query[COUNTRY_KEY]; }

Ссылка на документацию

Проблемы enum

Создаем enum и явно не указываем значения, тогда у каждого ключа по порядку будут числовые значения от 0 и далее.

С помощью этого enum затипизируем первый аргумент функции showMessage, в ожидании, что мы сможем передать только те коды, которые описаны в enum:

enum LogLevel {     Debug, // 0     Log, // 1     Warning, // 2     Error // 3 }  const showMessage = (logLevel: LogLevel, message: string) => {     // code... }  showMessage(0, 'debug message'); showMessage(2, 'warning message');

Если же передать не содержащееся в enum значение в качестве аргумента, мы должны увидеть ошибку «Argument of type ‘-100’ is not assignable to parameter of type ‘LogLevel’».

Но в typescript ниже версии 5.0 такой ошибки нет, хотя по логике она должна быть:

showMessage(-100, 'any message')

Также мы можем создать enum и явно указать числовые значения. Константе a указываем тип enum и присваиваем любое несуществующее число, которого нет в enum, например, 1. 

При использовании TS ниже 5 версии ошибки не будет.

enum SomeEvenDigit {     Zero = 0,     Two = 2,     Four = 4 }  const a: SomeEvenDigit = 1;

И еще момент: при использовании TypeScript ниже 5 версии, вычисляемые значения не могут быть использованы в enum.

enum User {   name = 'name',     userName = `user${User.name}` }

Ссылка на документацию.

Функции, у которых явно указан возвращаемый тип undefined, должны иметь явный возврат

В версиях TypeScript ниже 5.1 будет появляться ошибка в случаях, когда у функции явно указан тип undefined, но нет return.

function f4(): undefined {}

Ошибки не будет в следующих случаях:

function f1() {}  function f2(): void {}  function f3(): any {}

Закрепим. Если явным образом присвоить функции тип void или any, ошибки не будет. Она появится, если присвоить функции тип undefined, и только при использовании TypeScript версии ниже 5.1.

Ссылка на документацию.

Поведение enum’ов соответствует номинативной типизации, а не структурной

И это несмотря на то, что у TypeScript типизация, наоборот, структурная.

Создадим enum и функцию, аргумент которой типизируем этим enum. Попробуем вызвать функцию, передав в качестве значения этого аргумента строку, которая идентична одному из значений enum. Получаем ошибку в showMessage тип аргумента ‘Debug’ не может быть присвоен, так как ожидается тип enum ‘LogLevel’.

enum LogLevel {     Debug = 'Debug',     Error = 'Error' }  const showMessage = (logLevel: LogLevel, message: string) => {     // code... }  showMessage('Debug', 'some text')

Даже если мы создадим новый enum с такими же значениями, это не сработает.

enum LogLevel2 {     Debug = 'Debug',     Error = 'Error' } showMessage(LogLevel2.Debug, 'some text')

Решение – использовать объекты со значением as const.

const LOG_LEVEL = {     DEBUG: 'debug',     ERROR: 'error' } as const  type ObjectValues<T> = T[keyof T]  type LogLevel = ObjectValues<typeof LOG_LEVEL>;  const logMessage = (logLevel: LogLevel, message: string) => {     // code... }

В таком случае можно передавать что угодно, и не будет ошибки, ведь работаем с простым значением, и не важно, откуда оно передается.

logMessage('debug', 'some text') logMessage(LOG_LEVEL.DEBUG, 'some text')

Возможность возврата неправильного типа данных в функции с перегрузкой

Предположим, что хотим вернуть из функции строку, если 2 её аргумента являются строками. Создаём такие функции, затем проверяем, являются ли наши аргументы строками. При этом вернуть мы можем любой тип данных, несмотря на то что в первом шаге была указана строка.

function add(x: string, y: string): string function add(x: number, y: number): number function add(x: unknown, y: unknown): unknown {      if (typeof x === 'string' && typeof y === 'string') {                 return 100;     }      if (typeof x === 'number' && typeof y === 'number') {         return x + y     }      throw new Error('invalid arguments passed'); }

Далее ожидаем, что const будет содержать тип string, но получаем число.

const str = add("Hello", "World!"); const num = add(10, 20);

Передача объекта как аргумент функции с лишним свойством

Типизируя аргументы функций и классов, мы не можем добавлять лишние свойства, не указанные в типе или интерфейсе изначально. Ведь в таком случае мы просто передаём другую структуру в качестве аргумента.

Однако в typescript возможно нарушить это правило:

type Func = () => {   id: string; };  const func: Func = () => {   return {     id: "123",     name: "Hello!",   }; }; 

Для большей наглядности, создадим объект с настройками formatAmountParams, который передадим в функцию formatAmount. Как можно увидеть, объект с настройками может содержать лишние свойства и ошибки никакой не будет.

type FormatAmount = {   currencySymbol?: string,   value: number }  const formatAmount = ({ currencySymbol = '$', value }: FormatAmount) => {   return `${currencySymbol} ${value}`; }  const formatAmountParams = {   currencySymbol: 'USD',   value: 10,   anotherValue: 20 }

Нет ошибки, если передаем объект, который содержит лишние свойства:

formatAmount(formatAmountParams);

Получим ошибку, если создадим объект как аргумент функции и передадим с лишним свойством.

formatAmount({ currencySymbol: '', value: 10, anotherValue: 12 });

Можем столкнуться с неочевидным поведением, если захотим переименовать currencySymbol на currencySign.

Сначала изменим в типе, затем typescript подскажет, что надо изменить ключ в объекте с  currencySymbol на currencySign.

type FormatAmount = {   currencySign?: string,   value: number }  const formatAmount = ({ currencySign = '$', value }: FormatAmount) => {   return `${currencySign} ${value}`; }  const formatAmountParams = {   currencySymbol: 'USD',   value: 10 }  formatAmount(formatAmountParams);

Ошибок нет – можно подумать, что рефакторинг прошел без проблем. Но в formatAmountParams осталось старое название currencySymbol и вместо ожидаемого результата ‘USD 10′ мы получим $10’.

Потеря типизации при использовании Object.keys

Создадим объект obj. С помощью Object.keys создадим массив с ключами объекта и проитерируемся по этому массиву. Если в цикле обратимся к объекту по ключу, typescript скажет, что не можем этого сделать, так как общий тип ‘string’ не может быть использован в качестве ключа для объекта obj.

Возможное решение – скастовать тип с помощью конструкции as. Но это может быть небезопасно, потому что мы вручную устанавливаем, какой тип там будет находиться. Нужно привести к тому, чтобы [key] был не просто строкой, а ключом, и явно это указать.

const obj = {a: 1, b: 2}  Object.keys(obj).forEach((key) => {   console.log(obj[key])   console.log(key as keyof typeof obj) });

TypeScript может не распознать изменение типа данных

Создадим тип UserMetadata, как Map ключ-значение. На основе этого типа создаём cache и пытаемся получить значение по ключу ‘foo’ с помощью метода get. Всё работает как ожидается.

Затем создадим объект cacheCopy на основе cache. И также вызываем метод get. Typescript не подскажет, что что-то не так, но будет ошибка, так как у объекта нет метода get.

type Metadata = {};  type UserMetadata = Map<string, Metadata>;  const cache: UserMetadata = new Map();  console.log(cache.get('foo'));  const cacheCopy: UserMetadata = { ...cache };  console.log(cacheCopy.get('foo'));

Мерж интерфейсов

Интерфейсы в отличии от типов могут мержиться. Если в одном файле будут интерфейсы с одинаковыми названиями, то когда мы назначим этот интерфейс он будет содержать свойства из всех интерфейсов с одинаковыми названиями.

interface User {     id: number; }  interface User {     name: string; }  // Error: Property 'id' is missing in type '{ name: string; }' but required in type 'User', because User interfaces merged const user: User = {     name: 'bar', }

Более того, если у нас есть глобальные интерфейсы, например, предопределенные в самом typescript, они также смержатся. Например, если создадим интерфейс с именем comment, получим мерж интерфейсов, потому что comment уже существует в lib.dom.d.ts.

interface Comment {   id: number;   text: string; }  // Error: Type '{ id: number; text: string; }' is missing the following properties from type 'Comment': data, length, ownerDocument, appendData, and 59 more. const comment: Comment = {   id: 5,   text: "good video!", };

Ссылка на документацию.

Еще полезное

Если вам хочется закрепить информацию по теме, но не хочется перечитывать статью, можно посмотреть несколько роликов на youtube:


ссылка на оригинал статьи https://habr.com/ru/articles/775330/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *