Flow + tcomb = типизированный JavaScript
- суббота, 15 апреля 2017 г. в 03:14:15
Рано или поздно, все приходят к выводу, что нам нужна строгая типизация. Почему? Потому что проект разрастается, обрастает if-ами; функциональное программирование — всё функция — неправда, мне только что консоль сказала "undefined is not a function". Вот эти проблемы появляются всё чаще-чаще, становится сложнее отслеживать, возникает вопрос — давайте строго типизировать, хотя бы на этапе написания кода будет подсказывать.
Знаете рекламу: TypeScript — это надмножество JavaScript-а. Маркетинговый BS. Мы честно попытались, грубо говоря, переименовать проект из JS в TS — оно не заработало. Оно не компилируется, потому что некоторые вещи, с точки зрения TypeScript-а являются некорректными. Это не означает, что TypeScript — плохой язык, но продвигаться на идее надмножества, и подводить меня так, TypeScript — я не ожидал.
Как только вы вычеркиваете TypeScript, остаётся ровно одна альтернатива — Flow. Что я могу сказать про Flow? Flow мегакрутой тем, что заставит вас выучить систему типов OCaml, хотите вы того, или нет. Flow написан на OCaml. У него гораздо строже и гораздо мощнее вывод типов, чем у TypeScript-а. Вы можете переписывать проект на Flow частично. Количество бонусов, которые вам приносит Flow, сложно описать. Но, как всегда, есть парочка "но".
Хорошие. У нас начинают появляться вот такие штуки — это кусок редюсера:
type BuyTicketActionType = {|
type: BuyTicketActionNameType,
|}
type BuyTicketFailActionType = {|
type: BuyTicketFailActionNameType,
error: Error,
|}
Пайпы "|" внутри фигурных скобок означают строгий тип — только эти поля и ничего более. На вход редюсера обязаны приходить только такие-то экшены:
type ActionsType =
| BuyTicketActionType
| BuyTicketFailActionType
;
Flow это красиво верифицирует. Казалось бы всё превосходно, но нет. Flow работает только с типами. Приходится писать извращения:
type BuyTicketActionNameType = 'jambler/popups/buyBonusTicket/BUY_TICKET';
const BUY_TICKET: BuyTicketActionNameType
= 'jambler/popups/buyBonusTicket/BUY_TICKET';
Поскольку вы не можете объявить константу, и сказать, что такой-то тип является значением этой константы; проблема курицы и яйца, константа — это уже код, который должен быть типизирован, а типы не должны взаимодействовать с кодом. Поэтому приходится говорить, что тип BuyTicketActionNameType — это какая-то строка, и дальше, что константа BUY_TICKET имеет такой же тип, исключительно ради того, чтобы проконтролировать, что строка совпадает. Слегка извращение.
Что ещё. Эти строгие типы очень крутые, очень удобно позволяют выявлять опечатки и прочее; вот только они не понимают spread-оператор:
case OPEN_POPUP: {
const { config } = action;
return {
...state,
isOpen: true,
config,
};
}
То есть у вас есть state описанного типа, и вы говорите вернуть спред от state и новые поля; Flow не понимает, что мы спредим такие же поля, какие должны вернуть. Обещают это когда-нибудь поправить, Flow развивается очень быстро (пока есть обходной путь).
Но основная проблема Flow, что типы, которые вы пишите, напоминают предвыборную программу депутатов Верховной Рады Украины. То есть вы предполагаете, что некоторые типы будут туда приходить, а на самом деле туда приходит не совсем то, что вы ожидаете. К примеру, вы ожидаете, что в компонент всегда будет приходить пользователь, а иногда туда приходит null — всё, вы не поставили знак вопроса, Flow это никак не отловит. То есть полезность Flow начинает падать, как только вы начинаете его накручивать на существующий проект, где у вас в голове вроде как есть понимание, что происходит, но на самом деле это не всегда происходит так, как вы задумали.
Ещё есть backend-программисты, которые любят менять форматы данных, и не уведомлять вас об этом. Мы начинаем писать JSON-схемы, чтобы валидировать данные на входе и на выходе, чтобы в случае чего говорить, что проблемы на вашей стороне.
Но как только вы начинаете писать JSON-схемы, получаете два источника типизации: JSON-схемы и Flow. Поддерживать их в консистентном состоянии — такой же миф, как о поддержке актуальности JSDoc-ов. Говорят, где-то есть программисты, которые поддерживают JSDoc-и в абсолютно актуальном состоянии, но я их не встречал.
И тут на помощь приходит восхитительнейший плагин, который для меня является киллер-фичей, почему сейчас я выберу Flow, а не TypeScript почти на любом проекте. Это tcomb (babel-plugin-tcomb). Что он делает? Он берёт Flow-типы и реализует проверки в рантайме. То есть когда вы описываете систему типов, ваши функции в development-режиме будут автоматически проверять входные данные и выходные данные на соответствие типов. Не важно, откуда эти данные вы получили: в результате парсинга JSON, и так далее, и так далее.
Превосходная штука, как только вы подключаете в проект, следующие два дня понимаете, что все Flow-типы, которые у вас написаны, на самом деле не так. Он говорит: "слушай, ты тут написал, что приходит Event — это на самом деле SyntheticEvent реактовский". Ты же не подумал, что в React-е все Event-ы — это SyntheticEvent. Или там: "слушай, у тебя пришёл null". И каждый раз падает-падает-падает. Справедливости ради, падает только в development-режиме. Тот странный момент, когда в production всё продолжает работать, а разрабатывать невозможно. Но очень сильно помогает.
У нас есть функции и типы, tcomb просто транспилирует в assert-ы; но самое коварное, он выполняет на все типизированные объекты Object.freeze() — это означает, что вы не можете не просто добавить к объекту поле, вы даже в массив пушнуть ничего не можете. Вы любите иммутабельность? Ну так вот, пожалуйста. Вместе с tcomb вы будете писать иммутабельный код, хотите вы того, или нет.
Это конспект части доклада Хайп против реальности: год жизни с изоморфным React-приложением (Илья Климов)
PS
Сейчас перевожу свой фан-проект на Flow. Хочется странного, чтобы код компонента был выше, чем объявление типа для props.
До:
import React from 'react'
import PropTypes from 'prop-types'
const MyComponent = ({ id, name }) => {
//...
}
MyComponent.propTypes = {
id: PropTypes.number,
name: PropTypes.string,
}
После:
// @flow
import React from 'react'
const MyComponent = ({ id, name }: Props) => {
//...
}
type Props = {
id: number,
name: string,
}
Но теперь ESLint ругается на нарушение правила no-use-before-define. А менять конфигурацию ESLint в CRA нельзя. И выход есть, снова применяю прекрасный react-app-rewired. Кстати, подключить tcomb он тоже помог, вся магия внутри config-overrides.js.
И вишенка на торте. Flow + абсолютные пути для импорта:
# .flowconfig
[options]
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=src