Практичний TypeScript. React + Redux
В даний час розробка будь-якого сучасного фронтэнд-додатка складніше рівня 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[]) => R infer
? (...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
потрібно будемо самому подбати про більш правильною типізації цієї утиліти (можливо через написання своєї обгортки).