Неочевидные моменты 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. Тогда не придется вручную прописывать конструкцию из примера выше.
Сужение типа с помощью скобочной нотации
Создаем объект с типом ключ строка, значение строка или массив строк.
Затем обращаемся к свойству объекта, используя скобочную нотацию и проверяем, что тип возвращаемого значения объекта является строкой. В 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: