Как стать автором
Обновить

TypeScript в React-приложениях. 4. Глубокая типизация

Время на прочтение8 мин
Количество просмотров11K

Глубокая типизация предполагает обеспечение кода достаточно точными типами. Статья рассматривает эти подходы на примерах, встречающихся в приложениях на React.

Содержание

Код требует более точный тип, чем string

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

Рассмотрим пример с компонентом иконки:

import React from 'react';
import * as icons from './icons'

type Props = {
  name: string;
}

const Icon: FC<Props> => {
  const Component = icons[name]

  return Components ? (
    <Component/>
  ) : null
}

Код 4.1. Компонента иконки с недостаточно точным типом Props

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

import React from 'react';
import * as icons from './icons'

type Props = {
  name: 'add' | 'remove' | 'edit';
}

const Icon: FC<Props> => {
  const Component = icons[name]

  return (
    <Component/>
  )
}

Код 4.2. Компонент с более узким типом Props не требует дополнительной логики

Уменьшение логики за счёт контроля типов анализатором - очень важный плюс использования TypeScript в проекте, которым разработчики забывают пользоваться. Кроме того здесь связаны имена файлов иконок с их именами в коде, что вынуждает разработчиков добавлять файлы с именами соответствующими именам иконок в коде и наоборот.

Как это работает? Когда мы импортируем через звёздочку *, то получаем объект со всеми файлами в папке.

Файлы

add.svg
remove.svg
edit.svg

преобразуются в объект:

{
  add: add.svg
  remove: add.svg
  remove: edit.svg
}

Анализатор TypeScript типизирует его как объект с конкретными свойствами. Зная это, мы можем получить его тип через typeof и связать имена иконок с именами файлов через keyof

import * as icons from './icons'

type Props = {
  name: keyof typeof icons;
}

Код 4.3. Тип иконок привязан к именам файлов

Теперь разработчику не придётся добавлять в тип новые имена иконок — они автоматически берут тип из имён файлов.

Подобная привязка работает не только для файлов в папке, но и для отдельных экспортов внутри файла.

// файл utils

export const fn1 = () => {
  // некая логика
}

export const fn2 = () => {
  // некая логика
}


// файл импортирующий функции из utils
import * as utils from './utils'

type UtilNames = keyof typeof utils // 'fn1' | 'fn2'

Код 4.4. Тип имён функций привязан к именам функций в другом файле

Эта возможность позволяет типизировать паттерны фабрики и стратегии (когда вызывающему коду должно быть известно имя функции) без дополнительного обслуживания типа.

Авторское отступление

Если вы захотите вспомнить (или познакомиться) с паттерном фабрики и заглянете сюда, то увидите, что там так же предлагается более точная типизация на языке Java. Таким образом подходы в подробной типизации давно существуют, а понятие глубокой типизации нужно только для того, чтобы акцентировать на этом внимание разработчиков JavaScript, привыкших проверять данные, вместо того, чтобы правильно их типизировать.

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

Выведение типа из строки

Рассмотрим на примере с иконками более широкие возможности языка TypeScript.

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

addIcon.svg
removeIcon.svg
editIcon.svg

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

type IconNames = 'add' | 'remove' | 'edit';

type FileNames = `${IconNames}Icon`; // 'addIcon' | 'removeIcon' | 'editIcon'

Код 4.5. Пример литерального шаблонного типа

С помощью таких типов разработчик может создавать интересные типы, более узкие чем string и при этом завязанные на некие исходные типы. Но наша задача извлечь тип из существующих. Для этого воспользуемся ключевым словом infer:

import * as icons from './icons';

type FileNames = keyof typeof icons; // 'addIcon' | 'removeIcon' | 'editIcon'

type ParametrizedIconNames<T> = T extends `${infer IconNames}Icons` ? IconNames : never;

type IconNames = ParametrizedIconNames<FileName>; // 'add' | 'remove' | 'edit'

Код 4.6 Извлечение типа из шаблонной строки

Большинству разработчиков, пишущих приложения на TypeScript, данный код скорее всего сложно понять. Здесь есть дженерик, условный тип, шаблонный литеральный тип и ключевое слово infer.

type ParametrizedIconNames<T> = T extends `${infer IconNames}Icons` ? IconNames : never;

Тип ParametrizedIconNames через дженерик принимает некий тип T внутрь себя. Тернарный оператор в TypeScript, определяет, принадлежит ли данный тип определённому множеству и в зависимости от ответа возвращает другой тип. Так реализуются условные типы в TypeScript:

type MyType<T> = T extends 'str' ? string : number;

type MyString = MyType<'str'>;    // string
type MyNumber = MyType<boolean>;  // number
type MyNumber1 = MyType<string>;  // number, так как множество string не принадлежит
                                  // множеству из одного значения 'str'

Код 4.7. Условный тип

В условии типа ParametrizedIconNames, мы проверяем, принадлежит ли переданный тип T к шаблонной строке

`${string}Icons`

На данный момент в переменную шаблонной строки записан тип string, потому что там может быть какая угодно строка. Мы не можем знать, что это строка, но TypeScript сможет проанализировать соответствует ли тип T некой строке, которая оканчивается на 'Icons'.

Так как ему доступен исходный тип, он предоставляет разработчику возможность определить множество строк, которые предшествуют 'Icons' в исходном типе. И делается это с помощью ключевого слова infer. infer создаёт новую переменную, в которую записывается тип, то есть множество всех значений строк, подходящих под описание типа.

`${infer IconNames}Icons` // в переменной IconNames находится множество
                          // 'add' | 'remove' | 'edit'

Если входной тип T не включается во множество 'addIcon' | 'removeIcon' | 'editIcon', то в результате получится тип never. Этот тип пишется просто потому, что нам не нужны альтернативные типы. Мы можем что угодно сюда написать, но передать в дженерик такой тип, чтобы извлечь нужный тип.

Резюмируем тем, что извлечение типа из строки имеет важное значение, так как на строках строятся поля объекта, объекты получаются из импорта через звёздочку, различные паттерны проектирования используют строки. Глубокая типизация вертится вокруг строк.

Точность типов в реальной жизни

После сложных объяснений типизации настало время аллегорий. Наверняка вы помните фразу из советского кино:

Детям – мороженое, его бабе – цветы...

Здесь можно провести интересную параллель с глубокой типизацией. Дети и их мать функции, которые принимают некие данные — подарки — и в результате должны проявить нужное поведение, то есть благосклонность к незнакомому дяде.

Можно заметить, что мороженое и цветы - это не данные, а тип данных. Мороженое может быть пломбиром, разливным, в стаканчиках и так далее. То же самое с многообразием видов цветов. Если дети будут рады любому мороженому, то с цветами не всё так просто. Функция женщины имеет довольно-таки сложную логику и если мы нарвём ей одуванчиков, то реакция может быть не такой, как ожидается.

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

Изображение 4.1. Разработчик передаёт функции woman данные типа Flowers
Изображение 4.1. Разработчик передаёт функции woman данные типа Flowers

Ах, если бы вся жизнь была точно типизирована!..

Коду требуется более точный тип констант

Когда мы создаём константы, анализатор TypeScript сам определяет их тип. Иногда он это делает более точно, чем нам нужно, а иногда недостаточно точно.

Предположим, что нашему коду достаточно типа:

Record<string, boolean>

Мы создаём константу этого типа без явной типизации:

const config = {
  isActive: true,
  hasLink: false,
}
// тип { isActive: boolean; hasLink: boolean} 
// является подтипом Record<string, boolean>

Анализатор определил тип как более точный, что по большому счёту не является проблемой. Согласно принципу подстановки Барбары Лисков код, который использует более широкий тип, так же будет работать с более узким типом.

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

Эту проблему хорошо иллюстрируют константы массивы:

const steps = ['steps1', 'steps2'] // тип string[]

Если принимающий код будет обращаться к конкретным индексам, то тип данных станет более широким и его придётся сужать.

const currentSteps = steps[0] // string | undefined

Особенность массива в том, что не смотря на то, что он хранится в константе, он может изменить число элементов. И по этой причине TypeScript не может гарантировать, что в текущем элементе обнаружится строка, а не undefined.

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

const steps: [string, string] = ['steps1', 'steps2'];

Передаваемый индекс так же должен соответствовать длине кортежа, то есть быть более узким типом, чем number:

const getStep = (index: 0 | 1) => steps[index];

В связке с кортежами неплохо работают типы enum. Они документируют код и ограничивают тип индекса:

enum StepNames {
  FIRST,
  SECOND
}

const currentStep = getStep(StepNames.FIRST);

Типизация кортежей может выглядеть громоздкой в месте объявления константы и логично заранее типизировать их различные размеры:

type TupleLength = 2 | 3 | 4 | 5;

type Tuple<N extends TupleLength, T> =
  N extends 2 ? [T, T] :
    N extends 3 ? [T, T, T] :
      N extends 4 ? [T, T, T, T] :
        N extends 5 ? [T, T, T, T, T] : never

type TupleOfFive = Tuple<2, string>; // тип [string, string, string, string, string]

Код 4.8. Типизация кортежей различной длины и типа

Типы аргументов функции зависят друг от друга

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

Представим в React-приложении какой-нибудь сложный конфиг, который редактируется с помощью подобной функции:

// Внимание! Данный код демонстрирует только необходимость глубокой типизации,
// но не демонстрирует правильность подходов в проектировании логики

let initialConfig = {
  total: 1,
  isActive: true,
  role: 'senior'
}

const setConfig = (param, value) => {
  initialConfig = {
    ...initialConfig,
    [param]: value
  }
}

Функция setConfig не защищена от неверных входных данных. Можно легко задать правильный тип первого аргумента:

type ConfigParams = keyof typeof initialConfig; // 'total' | 'isActive' | 'role'

Но типизировать второй аргумент не так просто:

type ConfigValues = typeof initialConfig[ConfigParams]; 
// тип string | number | boolean

Значения переменной initialConfig вкупе действительно относятся к такому широкому типу. Чтобы заложить в тип аргументов зависимость друг от друга, нужно типизировать их через кортеж способом, показанным в прошлой статье в коде 3.8:

type SetFilterArgs<T extends ConfigParams> = [
  T,                      // тип относящийся ко множеству параметров ConfigParams
  typeof initialConfig[T] // тип, соответствующий переданному параметру в initialConfig
]

// функция принимает тип T и передаёт его в тип SetFilterArgs
// здесь прописывается extends, чтобы отличить дженерик от JSX
const setConfig = <T extends ConfigParams>(...args: SetFilterArgs<T>) => {
  const [param, value] = args;
  initialConfig = {
    ...initialConfig,
    [param]: value
  }
}

// при вызове функции тип T передаётся с первым аргументом
setConfig('total', 3);

// если передать неверные типы аргументов будет ошибка

setConfig('role', true);  // ошибка переданный тип boolean не соответствует
                          // типу string (который должен быть у полей с role)

setConfig('name', 'junior');  // ошибка 'name' не соответствует типу ConfigParams

Код 4.9. Типизация аргументов функции с зависимыми типами

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

  • код нуждается в логике проверки данных:

    • ветвления (if, switch) сужают тип входных данных;

    • обращение к полям объектам или элементам массива требует проверки на null и undefined (операторы ??, ||);

  • поведение функций меняется вплоть до их поломки, если передать неверные данные;

  • чтобы получить тип из исходного, нужно писать много логики в контексте типов.

Следующая статья: TypeScript в React-приложениях. 5. Связанная типизация

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии4

Публикации