Pull to refresh

Практический TypeScript. React + Redux

Reading time5 min
Views45K

Не понимаю, как вы вообще живёте без строгой типизации. Чем занимаетесь. Дебажите целыми днями?

В настоящее время разработка любого современного фронтэнд-приложения сложнее уровня hello world, над которым работает команда (состав которой периодически меняется), выдвигает высокие требования к качеству кодовой базы. Чтобы поддерживать уровень качества кода на должном уровне, мы во фронтэнд-команде #gostgroup идём в ногу со временем и не боимся применять современные технологии, которые показывают свою практическую пользу в проектах компаний самого разного масштаба.


О статической типизации и её пользе на примере TypeScript было много сказано в различных статьях и поэтому сегодня мы сосредоточимся на более прикладных задачах, с которыми сталкиваются фронтэнд-разработчики на примере любимого нашей командой стека (React + Redux).


"Не понимаю, как вы вообще живёте без строгой типизации. Чем занимаетесь. Дебажите целыми днями?" — не известный мне автор.


"нет, пишем целыми днями типы" — мой коллега.


При написания кода на TypeScript (здесь и далее в тексте будет подразумеваться стек сабжа) многие жалуются на то, что приходится тратить много времени на написание типов вручную. Хороший пример, иллюстрирующий проблему, функция-коннектор connect из библиотеки react-redux. Давайте взглянем на код ниже:


type Props = {
  a: number,
  b: string;
  action1: (a: number) => void;
  action2: (b: string) => void;
}

class Component extends React.PureComponent<Props> { }

connect(
  (state: RootStore) => ({
    a: state.a,
    b: state.b,
  }), {
    action1,
    action2,
  },
)(Component);

В чём здесь проблема? Проблема в том, что для каждого нового свойства, инжектируемого через коннектор, мы должны описать тип этого свойства в общем типе свойств компонента (React). Не очень интересное занятие, скажите вы, всё-таки хочется иметь возможность собирать тип свойств из коннектора в один тип, который потом один раз "подключать" к общему типу свойств компонента. У меня хорошая новость для вас. Уже сегодня TypeScript позволяет это сделать! Готовы? Поехали!


Мощь TypeScript


TypeScript не стоит на месте и постоянно развивается (за что я его люблю). Начиная с версии 2.8 в нём появилась очень интересная функция (conditional types), которая позволяет производить маппинги типов на основе условных выражений. Не буду вдаваться в подробности здесь, а просто оставлю ссылку на документацию и вставлю кусок кода из неё в качестве иллюстрации:


type TypeName<T> =
  T extends string ? "string" :
  T extends number ? "number" :
  T extends boolean ? "boolean" :
  T extends undefined ? "undefined" :
  T extends Function ? "function" :
  "object";

type T0 = TypeName<string>;  // "string"
type T1 = TypeName<"a">;  // "string"
type T2 = TypeName<true>;  // "boolean"
type T3 = TypeName<() => void>;  // "function"
type T4 = TypeName<string[]>;  // "object"

Как эта функция помогает в нашем случае. Посмотрев в описание типов библиотеки react-redux, можно найти тип InferableComponentEnhancerWithProps, который отвечает за то, чтобы типы инжектированных свойств не попали во внешний тип свойств компонента, которые мы должны явно задавать при инстанцировании компонента. У типа InferableComponentEnhancerWithProps есть два обобщенных параметра: TInjectedProps и TNeedsProps. Нас интересует первый. Давайте попробуем "вытащить" этот тип из настоящего коннектора!


type TypeOfConnect<T> = T extends InferableComponentEnhancerWithProps<infer Props, infer _>
  ? Props
  : never
;

И непосредственно вытаскивание типа на реальном примере из репозитория(который можно склонировать и запустить там тестовую программу):


import React from 'react';
import { connect } from 'react-redux';

import { RootStore, init, TypeOfConnect, thunkAction, unboxThunk } from 'src/redux';

const storeEnhancer = connect(
  (state: RootStore) => ({
    ...state,
  }), {
    init,
    thunkAction: unboxThunk(thunkAction),
  }
);

type AppProps = {}
  & TypeOfConnect<typeof storeEnhancer>
;

class App extends React.PureComponent<AppProps> {
  componentDidMount() {
    this.props.init();
    this.props.thunkAction(3000);
  }
  render() {
    return (
      <>
        <div>{this.props.a}</div>
        <div>{this.props.b}</div>
        <div>{String(this.props.c)}</div>
      </>
    );
  }
}

export default storeEnhancer(App);

В примере выше мы делим подключение к хранилищу (Redux) на два этапа. На первом этапе мы получаем компонент высшего порядка storeEnhancer (он же тип InferableComponentEnhancerWithProps) для извлечения из него инжектируемых типов свойств с помощью нашего типа-помощника TypeOfConnectи дальнейшего объединения (через интерсекцию типов &) полученных типов свойств с собственными типами свойств компонента. На втором этапе мы просто декорируем наш исходный компонент. Теперь, что бы вы не добавили в коннектор, это автоматически будет попадать в типы свойств компонента. Здорово, то, чего мы и хотели добиться!


Внимательный читатель заметил, что генераторы экшенов (для краткости далее по тексту упростим до термина экшена) с сайд-эффектами (thunk action creators) проходят дополнительную обработку с помощью функции unboxThunk. Чем же вызвана такая дополнительная мера? Давайте разбираться. Сначала посмотрим на сигнатуру такого экшена на примере программы из репозитория:


const thunkAction = (delay: number): ThunkAction<void, RootStore, void, AnyAction> => (dispatch) => {
  console.log('waiting for', delay);
  setTimeout(() => {
    console.log('reset');
    dispatch(reset());
  }, delay);
};

Как видно из сигнатуры, наш экшен не сразу возвращает целевую функцию, а сначала промежуточную, которую подхватывает redux-middleware для возможности произведения сайд-эффектов в нашей основной функции. Но при использовании этой функции в подключенном виде в свойствах компонента, сигнатура этой функции сокращается, исключая промежуточную функцию. Как это описать в типах? Нужна специальная функция-преобразователь. И снова TypeScript показывает свою мощь. Сначала опишем тип, который убирает промежуточную функцию из сигнатуры:


CutMiddleFunction<T> = T extends (...arg: infer Args) => (...args: any[]) => infer R
  ? (...arg: Args) => R
  : never
;

Тут, помимо условных типов, используется совсем свежее нововведение из TypeScript 3.0, которое позволяет выводить тип произвольного (rest parameters) количества аргументов функции. Подробности смотрите в документации. Теперь остается вырезать из нашего экшена лишнюю часть довольно жёстким образом:


const unboxThunk = <Args extends any[], R, S, E, A extends Action<any>>(
  thunkFn: (...args: Args) => ThunkAction<R, S, E, A>,
) => (
  thunkFn as any as CutMiddleFunction<typeof thunkFn>
);

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


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


P.S. При использовании динамической привязки экшенов в коннекторе через функцию и redux.bindActionCreators нужно будем самому позаботится о более правильной типизации этой утилиты (возможно через написание своей обёртки).


Update 0
Если кому-то показалось это решение удобным, то вот тут можно поставить лайк, чтобы тип-утилиту добавили в пакет @types/react-redux.


Update 1
Ещё немного типов, с помощью которых не нужно явно указывать тип инжектируемых пропсов хока. Просто берём хоки и вытаскиваем из них типы:


export type BasicHoc<T> = (Component: React.ComponentType<T>) => React.ComponentType<any>;
export type ConfiguredHoc<T> = (...args: any[]) => (Component: React.ComponentType<T>) => React.ComponentType<any>;

export type BasicHocProps<T> = T extends BasicHoc<infer Props> ? Props : never;
export type ConfiguredHocProps<T> = T extends ConfiguredHoc<infer Props> ? Props : never;

export type HocProps<T> = T extends BasicHoc<any>
  ? BasicHocProps<T> : T extends ConfiguredHoc<any>
  ? ConfiguredHocProps<T> : never
;

const basicHoc = (Component: React.ComponentType<{a: number}>) => class extends React.Component {};
const configuredHoc = (opts: any) => (Component: React.ComponentType<{a: number}>) => class extends React.Component {};

type props1 = HocProps<typeof basicHoc>; // {a: number}
type props2 = HocProps<typeof configuredHoc>; // {a: number}

Update2
Тип из сабжа теперь есть в @types/react-redux (ConnectedProps).

Tags:
Hubs:
+7
Comments17

Articles