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

152372b158ffe9dbd11a8aa86a7285db.png

Приветствую! Меня зовут Андрей Степанов, я 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. Тогда не придется вручную прописывать конструкцию из примера выше. 

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

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

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

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

const query: Record = {};

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[keyof T]

type LogLevel = ObjectValues;

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;

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:

© Habrahabr.ru