Как контролировать «любой» тип для достижения максимальной безопасности типов
TypeScript утверждает, что является строго типизированным языком программирования, построенным на основе JavaScript и предоставляющим лучшие инструменты в любом масштабе. Однако TypeScript включает тип any
, который часто может неявно проникнуть в базу кода и привести к потере многих преимуществ TypeScript.
В этой статье рассматриваются способы взять под контроль тип any
в проектах TypeScript. Будьте готовы раскрыть возможности TypeScript, добившись максимальной безопасности типов и улучшив качество кода.
Недостатки использования Any в TypeScript
TypeScript предоставляет ряд дополнительных инструментов для улучшения опыта и производительности разработчиков:
- Это помогает обнаружить ошибки на ранних стадиях разработки.
- Он предлагает отличное автодополнение для редакторов кода и IDE.
- Он позволяет легко проводить рефакторинг больших баз кода с помощью фантастических инструментов навигации по коду и автоматического рефакторинга.
- Он упрощает понимание базы кода, предоставляя дополнительную семантику и явные структуры данных через типы.
Однако как только вы начнете использовать тип any
в своей кодовой базе, вы потеряете все вышеперечисленные преимущества. Тип any
— это опасная лазейка в системе типов, и его использование отключает все возможности проверки типов и все инструменты, которые зависят от проверки типов. В результате теряются все преимущества TypeScript: пропускаются ошибки, редакторы кода становятся менее полезными и многое другое.
Например, рассмотрим следующий пример:
function parse(data: any) { return data.split(''); } // Case 1 const res1 = parse(42); // ^ TypeError: data.split is not a function // Case 2 const res2 = parse('hello'); // ^ any
В приведенном выше коде:
- Вы пропустите автозаполнение внутри функции
parse
. Когда вы вводитеdata.
в своем редакторе, вам не будут предложены правильные варианты доступных методов дляdata
. - В первом случае возникает ошибка
TypeError: data.split is not a function
, поскольку мы передали число вместо строки. TypeScript не может выделить ошибку, посколькуany
отключает проверку типов. - Во втором случае переменная
res2
также имеет типany
. Это означает, что одно использованиеany
может оказать каскадный эффект на большую часть кодовой базы.
Использование any
допустимо только в крайних случаях или для нужд прототипирования. В общем, лучше избегать использования any
, чтобы максимально эффективно использовать TypeScript.
Откуда взялся тип Any
Важно знать об источниках типа any
в базе кода, поскольку явное написание any
— не единственный вариант. Несмотря на все наши усилия избежать использования типа any
, иногда он может неявно проникнуть в базу кода.
В базе кода есть четыре основных источника типа any
:
- Параметры компилятора в tsconfig.
- Стандартная библиотека TypeScript.
- Зависимости проекта.
- Явное использование
any
в базе кода.
По первым двум пунктам я уже писал статьи Ключевые аспекты tsconfig и Улучшение типов стандартных библиотек. Пожалуйста, ознакомьтесь с ними, чтобы улучшить безопасность типов в ваших проектах.
На этот раз мы сосредоточимся на автоматических инструментах для управления появлением типа any
в базе кода.
Этап 1. Использование ESLint
ESLint — популярный инструмент статического анализа, который веб-разработчики используют для проверки лучших практик и форматирования кода. Он может применять стили кодирования и находить код, который не соответствует определенным рекомендациям.
ESLint также можно использовать с проектами TypeScript благодаря плагину typesctipt-eslint. Скорее всего, этот плагин уже установлен в вашем проекте. Но если нет, вы можете следовать официальному руководству по началу работы.
Наиболее распространенная конфигурация typescript-eslint
выглядит следующим образом:
module.exports = { extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', ], plugins: ['@typescript-eslint'], parser: '@typescript-eslint/parser', root: true, };
Эта конфигурация позволяет eslint
понимать TypeScript на уровне синтаксиса, позволяя вам писать простые правила ESlint, которые применяются к типам, написанным вручную, в коде. Например, вы можете запретить явное использование any
.
Пресет recommended
содержит тщательно отобранный набор правил ESLint для повышения корректности кода. Хотя рекомендуется использовать весь набор настроек, в этой статье мы сосредоточимся только на правиле no-explicit-any
.
нет-явного-любого
Строгий режим TypeScript предотвращает использование подразумеваемого any
, но не предотвращает явное использование any
. Правило no-explicit-any
помогает запретить написание any
вручную в любом месте кодовой базы.
// ❌ Incorrect function loadPokemons(): any {} // ✅ Correct function loadPokemons(): unknown {} // ❌ Incorrect function parsePokemons(data: Response<any>): Array<Pokemon> {} // ✅ Correct function parsePokemons(data: Response<unknown>): Array<Pokemon> {} // ❌ Incorrect function reverse<T extends Array<any>>(array: T): T {} // ✅ Correct function reverse<T extends Array<unknown>>(array: T): T {}
Основная цель этого правила — предотвратить использование any
в команде. Это средство укрепления согласия команды с тем, что использование any
в проекте не рекомендуется.
Это важнейшая цель, поскольку даже одно использование any
может оказать каскадное воздействие на значительную часть кодовой базы из-за выведения типа. Однако до достижения максимальной типовой безопасности еще далеко.
Почему «никакого явного» недостаточно
Хотя мы уже имели дело с явно используемым any
, в зависимостях проекта все еще существует множество подразумеваемых any
, включая пакеты npm и стандартную библиотеку TypeScript.
Рассмотрим следующий код, который, вероятно, можно увидеть в любом проекте:
const response = await fetch('<https://pokeapi.co/api/v2/pokemon>'); const pokemons = await response.json(); // ^? any const settings = JSON.parse(localStorage.getItem('user-settings')); // ^? any
Обеим переменным pokemons
и settings
был неявно присвоен тип any
. Ни no-explicit-any
, ни строгий режим TypeScript не предупредят нас в этом случае. Еще нет.
Это происходит потому, что типы для response.json()
и JSON.parse()
взяты из стандартной библиотеки TypeScript, где эти методы имеют явную аннотацию any
. Мы по-прежнему можем вручную указать лучший тип для наших переменных, но в стандартной библиотеке встречается почти 1200 экземпляров any
. Практически невозможно запомнить все случаи, когда any
может проникнуть в нашу кодовую базу из стандартной библиотеки.
То же самое касается внешних зависимостей. В npm много плохо типизированных библиотек, большинство из которых по-прежнему написаны на JavaScript. В результате использование таких библиотек может легко привести к большому количеству неявных any
в кодовой базе.
В общем, у any
еще много способов проникнуть в наш код.
Этап 2. Расширение возможностей проверки типов
В идеале нам бы хотелось, чтобы в TypeScript был установлен такой параметр, который заставлял бы компилятор жаловаться на любую переменную, получившую по какой-либо причине тип any
. К сожалению, такой настройки в настоящее время не существует и не ожидается.
Мы можем добиться такого поведения, используя режим проверки типов плагина typescript-eslint
. Этот режим работает с TypeScript, предоставляя полную информацию о типах из компилятора TypeScript в правила ESLint. С помощью этой информации можно писать более сложные правила ESLint, расширяющие возможности TypeScript по проверке типов. Например, правило может найти все переменные типа any
независимо от того, как был получен any
.
Чтобы использовать правила с учетом типов, необходимо немного изменить конфигурацию ESLint. Этот код может помочь:
module.exports = { extends: [ 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-type-checked', ], plugins: ['@typescript-eslint'], parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, root: true, };
Чтобы включить определение типа для typescript-eslint
, добавьте parserOptions
в конфигурацию ESLint. Затем замените пресет recommended
на recommended-type-checked
. Последний пресет добавляет около 17 новых мощных правил. В этой статье мы остановимся только на пяти из них.
нет-небезопасный-аргумент
Правило no-unsafe-argument
ищет вызовы функций, в которых переменная типа any
передается в качестве параметра. Когда это происходит, проверка типов и все преимущества строгой типизации теряются.
Например, давайте рассмотрим функцию saveForm
, которая требует объект в качестве параметра. Предположим, мы получаем JSON, анализируем его и получаем тип any
.
// ❌ Incorrect function saveForm(values: FormValues) { console.log(values); } const formValues = JSON.parse(userInput); // ^? any saveForm(formValues); // ^ Unsafe argument of type `any` assigned // to a parameter of type `FormValues`.
Когда мы вызываем функцию saveForm
с этим параметром, правило no-unsafe-argument
помечает ее как небезопасную и требует от нас указать соответствующий тип для переменной value
.
Это правило достаточно мощное, чтобы глубоко проверять вложенные структуры данных внутри аргументов функции. Поэтому вы можете быть уверены, что передача объектов в качестве аргументов функции никогда не будет содержать нетипизированные данные.
// ❌ Incorrect saveForm({ name: 'John', address: JSON.parse(addressJson), // ^ Unsafe assignment of an `any` value. });
Лучший способ исправить ошибку — использовать сужение типов TypeScript или библиотеку проверки, такую как Zod или Superstruct. Например, давайте напишем функцию parseFormValues
, которая сужает точный тип анализируемых данных.
// ✅ Correct function parseFormValues(data: unknown): FormValues { if ( typeof data === 'object' && data !== null && 'name' in data && typeof data['name'] === 'string' && 'address' in data && typeof data.address === 'string' ) { const { name, address } = data; return { name, address }; } throw new Error('Failed to parse form values'); } const formValues = parseFormValues(JSON.parse(userInput)); // ^? FormValues saveForm(formValues);
Обратите внимание, что разрешено передавать тип any
в качестве аргумента функции, которая принимает unknown
, поскольку при этом не возникает проблем с безопасностью.
Написание функций проверки данных может быть утомительным, особенно при работе с большими объемами данных. Поэтому стоит рассмотреть возможность использования библиотеки проверки данных. Например, для Zod код будет выглядеть так:
// ✅ Correct import { z } from 'zod'; const schema = z.object({ name: z.string(), address: z.string(), }); const formValues = schema.parse(JSON.parse(userInput)); // ^? { name: string, address: string } saveForm(formValues);
нет-небезопасное-назначение
Правило no-unsafe-assignment
ищет назначения переменных, в которых значение имеет тип any
. Такие присваивания могут ввести компилятор в заблуждение, заставив его думать, что переменная имеет определенный тип, в то время как данные могут иметь другой тип.
Рассмотрим предыдущий пример анализа JSON:
// ❌ Incorrect const formValues = JSON.parse(userInput); // ^ Unsafe assignment of an `any` value
Благодаря правилу no-unsafe-assignment
мы можем перехватить тип any
еще до передачи formValues
где-либо еще. Стратегия исправления остается прежней: мы можем использовать сужение типа, чтобы придать значению переменной определенный тип.
// ✅ Correct const formValues = parseFormValues(JSON.parse(userInput)); // ^? FormValues
нет-небезопасного-доступа к членам и не-небезопасного-вызова
Эти два правила срабатывают гораздо реже. Однако, исходя из моего опыта, они действительно полезны, когда вы пытаетесь использовать плохо типизированные сторонние зависимости.
Правило no-unsafe-member-access
не позволяет нам получить доступ к свойствам объекта, если переменная имеет тип any
, поскольку она может быть null
или undefined
.
Правило no-unsafe-call
не позволяет нам вызывать переменную типа any
как функцию, поскольку она может не быть функцией.
Представим, что у нас есть плохо типизированная сторонняя библиотека под названием untyped-auth
:
// ❌ Incorrect import { authenticate } from 'untyped-auth'; // ^? any const userInfo = authenticate(); // ^? any ^ Unsafe call of an `any` typed value. console.log(userInfo.name); // ^ Unsafe member access .name on an `any` value.
Линтер выделяет две проблемы:
- Вызов функции
authenticate
может быть небезопасным, так как мы можем забыть передать в функцию важные аргументы. - Чтение свойства
name
из объектаuserInfo
небезопасно, так как в случае неудачной аутентификации оно будетnull
.
Лучший способ исправить эти ошибки — рассмотреть возможность использования библиотеки со строго типизированным API. Но если такой возможности нет, вы можете дополнить типы библиотеки самостоятельно. Пример с фиксированными типами библиотек будет выглядеть так:
// ✅ Correct import { authenticate } from 'untyped-auth'; // ^? (login: string, password: string) => Promise<UserInfo | null> const userInfo = await authenticate('test', 'pwd'); // ^? UserInfo | null if (userInfo) { console.log(userInfo.name); }
нет небезопасного возврата
Правило no-unsafe-return
помогает случайно не вернуть тип any
из функции, которая должна возвращать что-то более конкретное. Такие случаи могут ввести компилятор в заблуждение, заставив его думать, что возвращаемое значение имеет определенный тип, тогда как на самом деле данные могут иметь другой тип.
Например, предположим, что у нас есть функция, которая анализирует JSON и возвращает объект с двумя свойствами.
// ❌ Incorrect interface FormValues { name: string; address: string; } function parseForm(json: string): FormValues { return JSON.parse(json); // ^ Unsafe return of an `any` typed value. } const form = parseForm('null'); console.log(form.name); // ^ TypeError: Cannot read properties of null
Функция parseForm
может привести к ошибкам выполнения в любой части программы, где она используется, поскольку анализируемое значение не проверяется. Правило no-unsafe-return
предотвращает подобные проблемы во время выполнения.
Это легко исправить, добавив проверку, гарантирующую соответствие проанализированного JSON ожидаемому типу. На этот раз давайте воспользуемся библиотекой Zod:
// ✅ Correct import { z } from 'zod'; const schema = z.object({ name: z.string(), address: z.string(), }); function parseForm(json: string): FormValues { return schema.parse(JSON.parse(json)); }
Примечание о производительности
Использование правил проверки типов приводит к снижению производительности ESLint, поскольку для вывода всех типов ему приходится вызывать компилятор TypeScript. Это замедление в основном заметно при запуске линтера в pre-commit хуках и в CI, но не заметно при работе в IDE. Проверка типов выполняется один раз при запуске IDE, а затем типы обновляются по мере изменения кода.
Стоит отметить, что простой вывод типов работает быстрее, чем обычный вызов компилятора tsc
. Например, в нашем последнем проекте с примерно 1,5 миллионами строк кода TypeScript проверка типа через tsc
занимает около 11 минут, в то время как дополнительное время, необходимое для загрузки правил ESLint с учетом типов, составляет всего около двух минут.
Дополнительная безопасность, обеспечиваемая использованием правил статического анализа с учетом типов, стоит нашей команды. В небольших проектах это решение принять еще проще.
Заключение
Контроль использования any
в проектах TypeScript имеет решающее значение для достижения оптимальной безопасности типов и качества кода. Используя плагин typescript-eslint
, разработчики могут выявлять и устранять любые вхождения типа any
в своей кодовой базе, что приводит к созданию более надежной и удобной в обслуживании кодовой базы.
При использовании правил ESlint с учетом типов любое появление ключевого слова any
в нашей базе кода будет преднамеренным решением, а не ошибкой или недосмотром. Такой подход защищает нас от использования any
в нашем собственном коде, а также в стандартной библиотеке и сторонних зависимостях.
В целом, линтер с учетом типов позволяет нам достичь уровня безопасности типов, аналогичного уровню статически типизированных языков программирования, таких как Java, Go, Rust и других. Это значительно упрощает разработку и сопровождение крупных проектов.
Надеюсь, вы узнали что-то новое из этой статьи. Спасибо за чтение!