12 советов по внедрению TypeScript в React-приложениях

в 11:20, , рубрики: React, ReactJS, TypeScript, Блог компании Tinkoff.ru, Программирование, Разработка веб-сайтов, рефакторинг, фронтенд

TypeScript — стандарт современной фронтенд-разработки. Согласно исследованиям State of JavaScript, TS вызывает явный интерес у программистов. По данным опроса за 2019 год, почти 60% респондентов пробовали TS и продолжают использовать, 22% не пробовали и желают изучить.

Эта статья — сборник советов о том, как внедрить и улучшить использование TypeScript. Первая половина советов общая, касающаяся подходов и инфраструктуры. Вторая — несколько особо полезных фишек языка.

12 советов по внедрению TypeScript в React-приложениях - 1

Я придерживаюсь контекста React-приложения, однако некоторые советы будут актуальны и в других экосистемах.

Совет 1: нетерпимость к ошибкам

Корректные типы — это доверие к написанному коду и облегчение рефакторинга. Чтобы типизация работала на вас, нужно строго относиться к проверкам типов:

  1. Сделайте проверку для dev-режима, чтобы сразу видеть ошибки.
  2. Добавьте валидацию в CI на каждый пул-реквест. Это даст уверенность, что в основной ветке всегда правильные типы.

Существует два основных способа компиляции ts-файлов: оригинальный TSC и @babel/preset-typescript. Метод babel интересен: из .ts файлов вырезается информация о типах, за счет чего сборка получается быстрой. Вот статья о внедрении.

Проверка типов в случае с babel добавляется дополнительно, с помощью fork-ts-checker-webpack-plugin. В режиме разработки проверки стоит делать асинхронно, чтобы не блокировать сборку и не мешать писать код. При пул-реквестах проверка типов, наоборот, должна останавливать билд, если найдена ошибка.

Совет 2: контракты с бэкендом

Общение с бэкендом через API само по себе подразумевает контракт обмена данными. Полезно выделить эти сущности в отдельный слой, создавая отдельные интерфейсы. Мы называем их DTO — Data Transfer Object.

Чтобы внедрить такой слой, нужны некоторые договоренности. Вот пример правил, которые можно внедрить в команде:

  • Имя интерфейса имеет суффикс Dto: FooBarDto.
  • Располагается в специальной папке.
  • Может измениться только с изменением API.
  • Данные такого интерфейса используются лишь для чтения, редактирование запрещено.

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

Если для описания API вы используете инструменты, например Swagger, посмотрите, как можно создавать подобные типы автоматически.

Совет 3: алиасы на any

Вы и ваша команда пишете превосходно типизированный код — без проблем, ошибок и точно в срок. Если это так, переходите к следующему пункту ;)

В реальности же возникают соблазны опустить типизацию, особенно при внедрении TS в существующий проект. Хочется явно или неявно использовать any. Есть адекватные причины: сложный код, срочная задача, нецелесообразность — усилия не окупают результат.

Вместо того чтобы игнорировать проблему, попробуйте ее «легализовать»:

export type TODO_ANY = any;

Плюсы:

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

const getArrayLength = (array: any[]) => array.length

Ограничения:

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

Также вам может пригодиться:

export type TODO_UNKNOWN = unknown;

В случае с any компилятор будет позволять практически любое действие. В случае с unknown — наоборот, практически любое использование приведет к ошибке. Поставили TODO_UNKNOWN, пока не ясен тип объекта → в будущем использовали объект → получили ошибку → исправили тип → вы восхитительны.

Совет 4: типы для библиотек

Если вы работаете с популярной js-библиотекой, поищите типы в DefinitelyTyped. Скорее всего, они уже написаны — подключаем и наслаждаемся жизнью.

А что если у вас внутренняя библиотека, в которой типов точно нет? Попробуйте сгенерировать их автоматически. Хорошая новость для использующих jsDoc: в TypeScript 3.7 tsc-компилятор научился генерировать типы .d.ts как из .ts, так и из .js файлов. 

Другая хорошая новость: существуют конвертеры js -> ts. Например, TypeStat помог мне справиться с типичными React-компонентами.

Плохая новость: вряд ли автоматически получится хорошая система типов. Рассчитывайте на «скелет», на который впоследствии надо будет нарастить «мясцо».

Совет 5: типы поверх типов

… и типами погоняют!

Бывает, что типы внешних зависимостей проекта не подходят — слишком общие (а вы хотели бы конкретней), содержат ошибку, не подходит версия — или же типы просто отсутствуют.

Обычно такие проблемы возникают во внутренних библиотеках (которые еще и часто обновляются), во внешних — довольно редко. Если проблема появилась, попробуем вручную определить/переопределить типы.

Опишем тип для модуля example. Подчеркну, что переопределяется только тип, имплементация не меняется:

declare module "example" {
    const foo: { bar: string };
    export default foo;
}

Такое переопределение лучше вынести в отдельную директорию и файл, например typings/example.d.ts. Подключим все содержимое из typings в поле compilerOptions.typeRoots в tsconfig:

{
    "compilerOptions": {
        "typeRoots": ["node_modules/@types", "./typings"]
    }
}

Вуаля — типы для example берутся из переопределения!

Совет 6: стайлгайд

Type или interface? const или enum? Как описать типы в функциональном компоненте? А в мемоизированном?

Когда TS только внедряется в команду, такие вопросы возникают постоянно. Аналогичный набор вопросов появляются у новичков в проекте: буря длится несколько недель, потом устаканивается привычный стиль кодирования.

Формализация правил — прекрасный способ сэкономить время на спорах и холиварах в пул-реквестах. Соберитесь командой и сформируйте свой стайлгайд.

  • Возьмите за основу популярные стайлгайды. Мне нравятся эти: react-typescript-cheatsheet, react-redux-typescript-guide, modern-typescript-with-examples-cheat-sheet.
  • Проработайте линтеры и форматеры — пусть черную работу делают машины.
  • Добавьте специфику своего проекта: типичные шаблоны, лучшие практики, худшие практики.
  • Зафиксируйте эти решения и ссылайтесь на них при код-ревью.
  • Обеспечьте команде возможность обсуждать и менять эти решения.

Хорошо построенный стайлгайд открывает чудеса CPDD (Copy Paste Driven Development): разработчики пишут в едином стиле, тратят меньше времени благодаря готовым бойлерплейтам. Не устраиваются эпичные баталии табы vs пробелы (пардон, типы vs интерфейсы), упрощается вхождение новичков в проект.

P.S.: Еще один источник вдохновения — библиотеки ts-утилит. Найдете не только полезные хелперы, но и код от профи в исходниках. Например, utility-types, SimplyTyped.

Совет 7: статистика

Внедряете что-то новое? Ведите учет статистики.

Зачем:

  • Отслеживать динамику работы.
  • Рефлексировать на промежуточных результатах.
  • Добавлять позитивчик команде — «Смотрите, какие мы молодцы, еще на шаг ближе к цели».

12 советов по внедрению TypeScript в React-приложениях - 2

⌘ ⌘ ⌘

На этом закончу блок с общими советами по использованию TS. 

Во второй части пройдемся по интересным приемам TS, которые стоит применять в своих проектах.

⌘ ⌘ ⌘

Не совет — минимум по TS

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

const example = {
    a: "foo",
    b: "bar"
};

// операторы typeof и keyof
type Ex1 = typeof example; // { a: string; b: string }
type Ex2 = keyof typeof example; // 'a' | 'b'

// популярные встроенные утилиты для работы с типами
type Ex3 = Pick<typeof example, "a">; // { a: string };
type Ex4 = Omit<typeof example, "a">; // { b: string };
type Ex5 = Partial<typeof example>; // { a?: string; b?: string} 
type Ex6 = Record<'a' | 'b', number>; // { a: number; b: number };

// утилиты для работы с функциями
const func = (a: string, b: number) => true;
type Ex7 = ReturnType<typeof func>; // boolean
type Ex8 = Parameters<typeof func>; // [string, number]

Совет 8: литеральный тип vs enum

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

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

Жизненный пример — простые пропсы компонентов:

// вариант с enum
enum ColorEnum {
    yellow = "yellow",
    transparent = "transparent"
}

const ExampleEnum = (props: { color: ColorEnum }) => <div>{props.color}</div>;

// вариант с литеральным типом
type ColorLiteral = "yellow" | "transparent";

const ExampleLiteral = (props: { color: ColorLiteral }) => <div>{props.color}</div>;

// применение обоих вариантов
const Example = () => (<>
        <ExampleEnum color={ColorEnum.yellow} />
        <ExampleLiteral color="yellow" />
 </>);

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

Предпочтите строковые литералы для простых проверок типов. Используйте enum для описания более сложной логики. При этом лучше отдать предпочтение const enum (однако эта структура не поддерживается babel, придется добавлять плагин).

Совет 9: const assertions

Еще один повод реже использовать enum — const assertions.

as const говорит компилятору, что данный объект меняться не будет. Значит, можно более детально вывести типы.

const loadAction = {
    start: "LOAD_START",
    done: "LOADED",
    error: "LOAD_ERROR"
} as const;

type LoadActionType = typeof loadAction;
type LoadActionKey = keyof LoadActionType; // 'start' | 'done' | 'error'
type LoadActionValue = LoadActionType[LoadActionKey]; // 'LOAD_START' | 'LOADED' | 'LOAD_ERROR'
// без "as const" LoadActionValue выводится как string

Также можно применять as const и к массивам:

const DIRECTION_ASC = 1;
const DIRECTION_DESC = -1;
const sortDirectionTypes = [DIRECTION_ASC, DIRECTION_DESC] as const;
type SortDirection = typeof sortDirectionTypes[number]; // -> 1 | -1

Это удобно использовать для перечислений. Например, проверить, что в словаре описаны все варианты:

const sortDirectionDescription: Record<SortDirection, string> = {
    [DIRECTION_ASC]: "От меньшего",
    [DIRECTION_DESC]: "От большего"
};
const sortDirectionDescription2: Record<SortDirection, string> = {
    [DIRECTION_ASC]: "От меньшего"
};
// Error: TS2741: Property '[-1]' is missing // in type '{ [DIRECTION_ASC]: string; }'

P.S.: Более подробно про const assertions можно прочитать в этой статье.

Совет 10: брендированные типы

Brand — маленькая утилита для создания уникальных («брендированных») типов. Обычно такие типы создаются на основе примитивных — строк, чисел.

Например, при работе с данными постоянно используются id. Иногда крайне важно отличать один тип id от другого (например, id пользователя от id его заказа в магазине), хотя и то, и другое — строки. С помощью Brand как раз можно получить два различных типа:

type Brand<T, U> = T & { __brand: U };

type UserId = Brand<string, "userId">; // "5435436" – пример id пользователя
type OrderId = Brand<string, "orderId">; // "42da-fet3-cdsl" – пример id заказа

declare const userID = "1111" as UserId; // какой-то пользователь
declare function getOrderById(id: OrderId): any; // какая-то функция

getOrderById(userID);
// Error: TS2345
// Type '"userId"' is not assignable to type '"orderId"'.

Чуть больше теории по этой теме — о номинальных и структурных типах — можно найти на Хабре, а еще тут и тут.

Совет 11: discriminated union types

Сложный термин discriminated union (разграниченные/размеченные объединения) легко понять на практике: создается объединение нескольких типов с общей частью (дискриминантом). При работе с таким объединением ts-компилятор может отфильтровывать варианты и конкретизировать тип.

Классический пример использования discriminated union прекрасно ложится на шаблон редьюсера. Благодаря общему полю type тип для payload выводится автоматически:

type Action =
    | { type: 'SET_ONE'; payload: string }
    | { type: 'SET_TWO'; payload: number };

export function reducer(state: AppState, action: Action): AppState {
    switch (action.type) {
        case 'SET_ONE':
            return { ...state, one: action.payload}; // `payload` is string
        case 'SET_TWO':
            return { ...state, two: action.payload}; // `payload` is number
        default:
            return state;
    }
}

Совет 12: never

Never — хорошая страховка от необработанных кейсов. Возьмем из предыдущего пункта, только без 'SET_TWO', и с добавкой typeguard: never;

type Action =
    | { type: 'SET_ONE'; payload: string }
    | { type: 'SET_TWO'; payload: number };

export function reducer(state: AppState, action: Action): AppState {
    switch (action.type) {

        case 'SET_ONE':
            return { ...state,
                        one: action.payload}; // `payload` is string

        default:
            const typeguard: never = action;
            return state;
            // Error: TS2322: Type '{ type: "SET_TWO"; payload: number; }'
            // is not assignable to type 'never'.
    }
}

Компилятор выводит тип для typeguard и приходит к противоречию. С одной стороны — тип которого нет, never. С другой — выводимый тип 'SET_TWO'. Типы не сходятся, получаем ошибку.

Еще один интересный (но редкий) кейс использования never в React — запретить вложенность или передачу пропсов:

type IconProps = {
    size: 's' | 'm';
    children?: never;
    foo?: never;
};

const Icon: React.FC<IconProps> = () => <div>Whatewer</div>;
    
const Example = () => (<>
    <Icon size="s"> <div /> </Icon> {/* Ошибка – передается children */}
    <Icon size="s" foo="123"/> {/* Ошибка - указан foo */} 
</>);

Также never вовсю используется для условных типов (conditional-types). Вот классная статья на эту тему, если все примеры до этого были для вас слишком легкие.

Заключение

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

Проект получает левел-ап, и вместе с новыми преимуществами приходят и новые задачи — поддержка корректной системы типов. Надеюсь, описанные советы помогут вам улучшить свою систему.

А вы уже используете что-либо из описанных приемов? Можете поделиться своими? Добро пожаловать в комментарии!

Автор: Андрей Алексеев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js