Как контролировать «любой» тип для достижения максимальной безопасности типов

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:

  1. Параметры компилятора в tsconfig.
  2. Стандартная библиотека TypeScript.
  3. Зависимости проекта.
  4. Явное использование 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 и других. Это значительно упрощает разработку и сопровождение крупных проектов.

Надеюсь, вы узнали что-то новое из этой статьи. Спасибо за чтение!

Полезные ссылки