В настоящее время разработка любого современного фронтэнд-приложения сложнее уровня 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
нужно будем самому позаботится о более правильной типизации этой утилиты (возможно через написание своей обёртки).
Комментарии (8)
Voronar Автор
30.11.2018 15:51-1Интересный факт подмечу.
С новым Hooks API все эти телодвижения с типами просто не нужны, потому что нет хоков, а есть только функция, которая возвращает типизированный кусок стора напрямую в функциональный компонент.)Voronar Автор
30.11.2018 17:46-1Судя по минусу, вы не любитель новых хуков.
Почитайте пару статей, попробуйте в своих проектах и потом одумаетесь.
Kotman34
А нафига Йозеф Геббельс на КДПВ? Известный программист, да?
CaptainCrocus
Он немножечко и Франц Кафка, но немножечко.