В настоящее время разработка любого современного фронтэнд-приложения сложнее уровня 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).