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

TypeScript в React-приложениях. 6. Изящная типизация

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

Заключительная в цикле статья подводит итоги и рассказывает о том, как должна выглядеть типизация React-приложений по мнению автора. Также в конце статьи располагается опрос для получения обратной связи.

1. Как типизировать данные
2. Как понимать типы
3. Как использовать типизацию
4. Глубокая типизация
5. Связанная типизация
6. Изящная типизация

Три столпа типизации

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

  1. Естественное сужение типов.

  2. Глубокая типизация.

  3. Связанность типов.

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

Типизация как документирование моделей данных

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

const [current, setCurrent] = useState<string>('');

Код 6.1. Избыточное описание типа в дженерике

Если следовать этому подходу, то мы будем чаще проникать в то, что находится в типе, а не глядеть, то что написано в коде. Это позволит находить более компактные решения для типизации.

// контекст типов засорён избыточным типом null
const [data, setData] = useState<Data | null>(null);

// хотя можно использовать undefined, который уже заложен в типе useState
const [data, setData] = useState<Data>();
setData(undefined); // аргумент принимает тип Data | undefined

Код 6.2. Компактное решение для хранения в состоянии отсутствие объекта

Использование библиотек, противоречащих идеям типизации

Библиотека lodash идеально подходит для трансформации данных, если бы не отсутствие типизации.

import lodash from 'lodash';

const obj = {
  user: {
    id: 123
  }
}

const id = lodash.get(obj, 'user.id'); //тип any

Код 6.3. lodash мешает TypeScript определять тип переменной

Использование метода get, не гарантирует отсутствие ошибок. Вы можете сузить тип с помощью оператора as и ошибиться в записи второго аргумента. В случае изменения константы obj или исходного типа поля id, вам нужно самостоятельно определить, как менять логику её получения и обслужить тип в приведении через as. Если бы id получался прямым обращением к полю, то анализатор TypeScript подсветил бы место проблемы.

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

import React from 'react';
import { Form } from 'react-final-form';

const FormComponent = () => (
  <Form    
    mutators={{
      customFn: (args: MyType, state, utils) => {...}
    }}
    render={(renderProps) => {
      const customFn = renderProps.form.mutators?.customFn; // тип (...args: any[]) => any | undefined
    }}
    // другие пропсы
  >
  </Form>
);

Код 6.4. react-final-form не связывает типы пропсов

Библиотека react-final-form связывает логикой переданные пропсы в компонент Form, но не связывает их типы. Таким образом разработчик обязан сужать типы самостоятельно.

Иногда подобные ситуации вынужденные, но при проектировании приложения следует учитывать абсурдность идеи совмещения паттернов антитипизации с TypeScript.

Определение базового типа

При связывании типов в приложении нужно разобраться от какого типа должен строиться другой. Иногда кажется, что самый большой по данным тип и является базовым, а все остальные очень удобно построятся из него. Это не всегда соответствует действительному удобству. Большой тип может намного удобнее собираться из маленьких. Тут вопрос к тому, какие данные являются первоисточником для определения типа и их влияние на него требует гибкости других.

В небольшом проекте были медальки (достижения) за действия пользователя. При проектировании приложения, тип, отвечающий за характеристики медалек, был независимым. Но при описывании зависимых от него данных, стало ясно, что он сам основан на другом типе, отвечающем за сбор статистики действий пользователя. Оказалось, что условия для получения медалек имеют почти такой же тип как сама статистика. Это привело к переосмыслению структуры моделей и упростило логику получения медалек пользователем.

// названия метрик для сбора статистики
type Metrics = 'action1' | 'action2' | 'action3';

//тип статистики действий по метрикам
type Statistics = Record<Metrics, number>;

// тип условий для получения медалек (похож на тип Statistics)
type Conditions = Omit<Statistics, 'action1'>

// тип описывающий имена достижений
type AchieveNames = 'junior' | 'middle' | 'senior';

// тип описывающий условия достижений для каждой медальки (вначале казался базовым)
type AchievementConfig = Record<AchieveNames, Condition>;

// тип описывающий, какими медальки получены пользователем
type UserAchievements = Record<AchieveNames, boolean>;

Код 6.5. Формирование исходных и зависимых типов

Создание типов удобно до написания логики. Это позволяет не хранить в голове следующий шаг проектирования кода, уменьшить путаницу и ошибки, а в результате ускорить разработку.

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

Когда мы отправляем данные на бэк, то формируем модель, соответствующую контракту в АПИ. Таким образом тип данных, отправляемых на бэк, зависит от этого контракта, а не от данных присылаемых с бэка (даже если поля этих типов совпадают). Правильным будет записать независимый тип. Гибкость такого подхода кроется в том, что в случае изменения контракта, мы будем работать только с типами трансформации данных для бэкэнда.

Разные стадии типизации проекта

Следовать описанным подходам удобно при проектировании нового приложения. Многие проекты переходили на TypeScript постепенно и содержат легаси код. Наличие нетипизированного рабочего кода в целом не проблема. Проблема таких проектов в том, что разработчики по инерции пишут новый код, используя старые практики.

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

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

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

type Props = {
  id: string;      // пропс, связанный с новой типизированной логикой
  data: any; // пропс, связанный с нетипизированной логикой
}

Код 6.6. Пример пересечения в коде нетипизированных и типизированных данных

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

Книга на основе цикла статей

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

Спасибо за внимание! Желаю всем изящной типизации их кода!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какими характеристиками должна обладать книга на основе этого цикла статей?
15.38% Книга по этому циклу статей не нужна4
57.69% Хорошо, если по этому циклу статей будет написана отдельная книга15
15.38% В статьях слишком много воды, аллегорий и т.п., что совсем не нужно книге4
34.62% Хорошо если в книге будут примеры из жизни и весёлые аллегории, как и в статьях9
46.15% Книгу лучше обогатить подробными примерами кода12
19.23% Лучше сделать акцент на тексте, а код использовать компактный5
50% Книга как и статьи должна быть рассчитана на практикующих TypeScript разработчиков13
34.62% В книге лучше совместить объяснение подходов с обучением языку TypeScript9
15.38% Мне бы хотелось приобрести бумажную версию книги4
50% Мне достаточно электронной версии книги (красиво свёрстанный PDF-файл)13
7.69% Я бы заплатил(а) больше денег за цветную бумажную книгу (синтаксис кода, схемы и картинки)2
0% Достаточно чёрно-белой печати бумажной книги, зато не очень большая цена0
7.69% Пусть бумажная книга будет в мягкой обложке2
7.69% Бумажная книга должна быть в твердой обложке2
3.85% Книгу можно делать нестандартных размеров (например, альбомный вид)1
11.54% Размер бумажной книги лучше делать стандартным, как большинство книг на полке3
Проголосовали 26 пользователей. Воздержались 7 пользователей.
Теги:
Хабы:
Всего голосов 5: ↑5 и ↓0+5
Комментарии0

Публикации

Истории

Работа

Ближайшие события

Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область