🔥

Тред (Майк Башуров)


Следующий тред про то как я вижу идеальный проект на ТС (и почему, естественно) Как будто нам дали требования и мы стартуем новый Про енамы, декораторы, branded types, discriminated unions, инварианты и другое Поехали

Первым делом установили npm i -D typescript И инициализировали конфигурацию npx tsc --init Дефолты на данный момент крайне адекватные, там включен esModuleInterop, strict и всякое другое

На esModuleInterop остановимся поподробнее Опция появилась в 2.7 и помогла сделать поведение импортов консистентым с бабелем Раньше когда нужно было импортнуть содержимое module.exports из commonjs модуля в ТС необходимо было писать import * as React from 'react'

Против import React from 'react' у бабеля Реальность показала что прав был бабель: * as React это namespace import по спеке и он не Callable но в commonjs модуле может быть написано module.exports = function foo() {} опа, нарушили стандарт

Кто хочет подробно разобраться в теме: я в свое время накатал объемную статью (осторожно, Инглиш) itnext.io/great-import-s… почитайте, если что-то непонятно, задавайте свои ответы

Окей, с модулями разобрались, все стрикты врубили, что дальше Дальше стоит определиться с тем какие фичи языка использовать Мой rule of a thumb: писать будто бы современном ECMAScript + типы Более того, именно в эту сторону сейчас движется ТС

Почти все фичи которых нет хотя бы на stage 4 были введены довольно давно Большинство новых фич не относящихся с ECMAScript спеке влияют исключительно на типы Какие в итоге фичи нестандартные? namespaces, enums, decorators и reflect metadata

В 2.4 завезли string enums enum Colors { Red = "RED", Green = "GREEN", Blue = "BLUE", } Несмотря на их кажущуюся пользу я считаю их вредными и буду подробнее об этом говорить в секции "Как Я такой-растакой буду указывать как ВЫ должны использовать ТС" (шучу)
namespaces уже совсем мало кому нужны, не вижу смысла на них останавливаться стоит однако заметить что в тайпингах (.d.ts) они все еще очень полезные, но тайпинги это вообще другой мир следующие на очереди енамы twitter.com/jsunderhood/st… итак, в чем с ними проблема

Начнем с того что енамов в ТС 4 вида enum, const enum, declare enum и declare const enum Все они имеют некоторые особенности и отличаются друг от друга (некоторые пишут в рантайм, некоторые не умеет траспилить бабель и т.д.) Те что пишут в райнтайм, пишут туда страшные вещи
notion image

Извиняюсь, 6 там не к месту (но и без нее непросто) В третьих помните я говорил чтобы получить тип от рантаймового значения надо сделать typeof Так вот енам, он особенный, ему не надо typeof, он сразу одновременно и тип и объект!

Стоит ли также упомянуть что enum врядли станет когда-нибудь частью стандарта? И при всем при этом абсолютное большинство кейсов енама покрывается двумя вещами: объектами и string literal union

Если вам нужен объект с заранее известными полями в которых будут хранится строковые значения, возьмите объект! enum Colors { Red: 'red', Blue: 'blue' } прекрасно транслируется в const Colors = { Red: 'red', Blue: 'blue' }

если же у вас есть какое-то апи завязанное на строки, к примеру enum RequestType { GET: 'GET' POST: 'POST' } const fetch = (type: RequestType) => ... Вместо енама вы можете использовать юнион type RequestType = 'GET' | 'POST' const fetch = (type: RequestType) => ...

С вышеназванными кейсами видимо понятно, так когда же енам все еще полезен? 1 Чтобы 1в1 скопировать енамы с бэка и не париться. То есть у вас есть скажем джава и шарп на бека и они используют енам у себя и в пейлоаде апи Вы просто копируете в ТС и вуаля, синхронизировались

2 Для придавания семантики числовым опциям. Этим опять же может грешить бэк и принимать вместо строковых ключей - числовые (которые потом завязываются на их енам) Если в случае со строкой fetch('GET') читается прекрасно, то fetch(0) - not so much enum RequestType { GET:0, }

Ладно, енамы, что еще? Декораторы! С ними... Сложно. Мне как фича языка они нравятся, НО Есть ощущение (может кто-то пруфанет) что декораторы (и reflect metadata) завезли ради Angular и просто надеялись что потом и в спеку войдет Чуда как всегда не случилось

И декораторы уже кажется два раза падали с stage 3 на stage 2 с ломающими изменениями В итоге состояние дектораторов в ТС и ныне там и видимо они ждут когда наконец-то можно будет имплементировать новую версию Но страдают как всегда пользователи

Во-первых декораторы не могут влиять на типы декорируемой сущности поэтому написать @connect(mapStateToProps) const Foo: React.FC<{}> = props => ... не выйдет Точнее написать-то выйдет, но вот убрать StateProps из пропсов не получится

В итоге при использовании компонент потребует лишние пропсы Можно конечно написать кучу явных приведений, но это только усугубляет ситуацию А во-вторых врядли миграция на новые декораторы будет гладкой. Поэтому тем кто на них завязался (привет старый MobX) будут огорчены

Но если уж инвестируете в декораторы (например Nest) то еще есть reflect-metadata Это вот уж точно чисто для ангуляра штукенция чтобы декоратор имел доступ не только к дескриптору элемента, но и к его типу Для этого тип записывается в рантайм

Несмотря на то что фича довольно крутая, меня очень смущает то что ее никто кроме Анга не использует Но судя по всему подвижки имеются, вот например @rm_baad будет на @HolyJSconf рассказывать про то как они это юзают (как я нативочку то ввернул, а?)

Со старыми фичами разобрались, про остальное продолжим завтра Stay tuned

I'm back in black! Давайте еще посмотрим на опции компилятора Стоит включить --forceConsistentCasingInFileNames: дело в том, что на Windows файловая система case-insensitive, а на юниксе нет Это может привести к интересным багам которые еще и неприятно фиксить

То есть если кто-то с винды напишет import Component from './mYComPoNenT' это соберется у него и упадет на CI (если он не винда) и всех остальных Но проверить на винде правильный кейсинг нельзя, фс не позволяет! Для этого придумали вот такой костыль:

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

module выставляем в ESNext: собирать наверняка будем каким-нибудь вебпаком или роллапом и это позволит использовать чанки через динамический импорт и трешейкинг moduleResolution стоит явно выставить в "node", это решает некоторые странные проблемы

От компилятора переходим к организации проекта. Сразу советую создать в корне папку typings и указать ее в typeRoots "typeRoots": ["./typings", "./node_modules/@types"] если вам понадобиться дополнить (или создать) тайпинги то просто создайте typings/some-library.d.ts

И там уже declare module "some-library" { // blabla }

Также я считаю всевозможные опции типа webpack alias, tsconfig.paths etc. вредными Почему? Это нестандартная фича и для любого инструмента который работает с вашим проектом вам придется дублировать это Вот сделали вы webpack alias, пишете в ТС import Obj from 'myRoot'

А ТС вам и говорит, не знаю что за myRoot, он же не знает ничего про вебпак Хорошо, добавили в tsconfig.paths, ТС успокоился. Запускаем тесты и получаем ошибку резолва модуля. Фиксим ее, падает сторибук. Аккуратно наследуем конфиг сторибука от вашего вебпака, ну вы поняли

Вы наверняка спросите, а как тогда быть c import Something from '../../../../../../this/is/root/then/its/this/folder' Я считаю нужно уплощать структуру. Код и его зависимости это граф, и попытки выразить их через дерево (файловая система) обречены на провал

Про использование symbolic links в кодовой базе даже говорить не хочется Поэтому файловую систему нужно использовать для группировки высокоцелостных (high cohesive) кусков кода вместе Вот здесь парень хорошо объясняет youtu.be/xBa0_b-5XDw

Кратко, вместо логической группировки actions/notifications.ts actions/chat.ts reducers/notifications.ts reducers/chat.ts делаем группировку по функционалу notifications/reducer.ts notifications/actions.ts chat/reducer.ts chat/actions.ts Нейминг выбирайте сами

В редаксе это называется ducks, детали могут варьироваться, но я думаю общая мысль понятна freecodecamp.org/news/scaling-y…

Rule of a thumb: не создавать папку без надобности, в случае необходимости стараться группировать по функционалу Это серьезно сократит вложенность

Пока сделаем паузу, потом продолжим про всякие фп концепты и как мы будет их использовать в нашем проекте

Вчера вечером забухал (шучу, были дела) поэтому продолжаем с утреца

Где код писать понятно, как код писать понятно, но какой код писать?? Давайте поглядим на всякие штуки которые в теории должны помочь вам с поддержкой, корректностью и читаемостью

Начнем с инвариантов. Если ко/контра вариантность может показаться сложной для понимания, то в инвариантах сложное только название Инварианты это возможные и взаимоисключающие состояния объекта

Пример: сколько возможных состояний у типа? type Data = { data?: string loading?: boolean error?: Error } Подсказка: нас не интересуют разные значения string или Error, только их наличие

Но сколько состояний в реальности? Вероятно 4 | { loading: false } | { loading: true } | { data: string, loading: false } | { error: Error, loading: false } Это и есть нужные нам инварианты и стандартная фп мантра про Making impossible states impossible youtube.com/watch?v=IcgmSR…

На таком маленьком примере не очень понятно в чем профит, но такой подход зачастую отлично ложиться на разные доменные сущности Скажем у нас есть несколько категорий юзеров, например у золотого юзера есть поле которое говорит о том когда закончится его подписка

Опять же если выражать это через опциональные поля выйдет как-то так { username: string role: 'regular' | 'gold' subscriptionEnd?: Date } Потом нам надо в каком-то месте для золотого юзера отобразить время окончания подписки

Пишем const renderSubscriptionEnd = (date: Date) => {...} if (user.role === 'gold') renderSubscriptionEnd(user.subscriptionEnd) И получаем ошибку что subscriptionEnd can be undefined

Окей, давайте проверим const renderSubscriptionEnd = (date: Date) => {...} if (user.role === 'gold' && user.subscriptionEnd) renderSubscriptionEnd(user.subscriptionEnd) И это работает, но мы то знаем что у всех золотых юзеров это поле непустое, так зачем писать этот код?

Давайте объясним это компилятору! type BaseUser = { username: string } type RegularUser = BaseUser & { role: 'regular' } type GoldUser = BaseUser & { role: 'gold' subscriptionEnd: Date }

И теперь тот же самый код работает! const renderSubscriptionEnd = (date: Date) => {...} if (user.role === 'gold') renderSubscriptionEnd(user.subscriptionEnd)

Потому что раньше было 4 варианта юзера { role: 'regular' } { role: 'regular', subscriptionEnd: new Date() } { role: 'gold' } { role: 'gold', subscriptionEnd: new Date() } А мы превратили их в 2 { role: 'regular' } { role: 'gold', subscriptionEnd: new Date() }

И компилятору легче просечь что вы имели ввиду Этим мы получили тот самый discriminated union о котором я говорил ранее. Он же variant, он же тип-сумма ТС теперь знает что если role === 'gold' то там еще будет дополнительное поле с датой

А знаете что еще классно на это ложится? Экшены в редаксе! Так как type это у нас string literal (к тому же с помощью as const мы научились делать типы экшенов минимальными усилиями) то экшены можно дискриминировать (ПРОСТО ОТЛИЧАТЬ, ТУТ НЕТ ДИСКРИМИНАЦИИ, ВСЕ ПОД КОНТРОЛЕМ)

Также можно делать exhaustive checking. Это следующая фигня, если мы выразим те экшены которые обрабатывает редьюсер в виде discriminated union мы сможем проверить все ли экшены обработаны Код максимально упрощен:

type LoadAction = { type: 'LOAD' } type SuccessAction = { type: 'SUCCESS' } const reducer = (state, action: LoadAction | SuccessAction) => { switch (action.type) { case 'LOAD': return case 'SUCCESS': return } const _exhaustiveCheck: never = action }

Работает это так, что ТС зная что приходят два вот таких экшена (которые он умеет отличать друг-от-друга, hence the 'discriminated') и в обоих случаях мы выходим, значит то что после свитча быть не может! В итоге в экшене не лоад и не саксес, а значит там ничего never

never обладает следующими свойствами never is assignable to anything nothing except never is assignable to never То есть его можно присвоить чему угодно. В принципе логично, этой ситуации же как бы в рантайме возникнуть не должно Но ему ничего кроме never присвоить нельзя

Соответсвенно когда мы забудем обработать какой-то экшен то ТС будет ругаться что этот экшн not assignable to never. На моей практике это позволило поймать интересный баг: Был подобный редьюсер который обрабатывал экшены и был примерно такой код

case 'ACTION': return { ...state, blaba } При слиянии потеряли 'return {' и потом вернули '{', а return забыли. В итоге остался неприкаянный object literal который ничего не делал, но код был валидный Когда прикрутили exhaustive check нашли это (экшн был редкий)

Теперь о номинальных типах. Номинальная совместимость (по имени) противопоставляется структурной совместимости (по структуре) То есть в ТС это нормально type A = { field: string } type B = { field: string } const foo = (obj: B) => {} const a: A = { field: '' } foo(a)

А например в C# нет class A { public field: string } class B { public field: string } void Foo(obj: B) {} Foo(new A()) // Ошибка

Так вот, о номинальных типах. Во-первых в ТС их нативно нет, приходится костылить Во-вторых, собственно, нафига? Самый распространенный случай в моей практике это номинальные примитивы. Примитивы это boolean, string, number. Бывает так что тип примитива один, но семантика разная

Например id у разных сущностей. Представим что у нас есть компонент который принимает id юзера и грузит его карточку. Как защититься от того чтобы не передать туда любой другой number? Баг (встречал такое) userId={userSubscription.id} Надо userId={userSubscription.userId}

На помощь приходят брендированные типы type UserId = number & { readonly __brand: unique symbol } declare function foo(userId: UserId): void type User = { id: UserId } const user: User = { id: 1 as UserId } foo(user.id) foo(1) //ошибка typescriptlang.org/play/#code/C4T…

Таким же образом можно брендировать и объекты, чтобы защититься от похожего по структуре, но неподходящего объекта Если интересно, то вот это и другое есть в этом докладе youtu.be/m0uRxCCno00 Еще там был доклад Сергея Черепанова, но я не могу найти его в открытом доступу

И напоследок немного хардкора: opaque types Opaque (непрозрачный) type это такой тип который Номинальный Умеет скрывать свою имплементацию Нужен он нам для того чтобы делать явное управление состоянием по ФП-шному при этом имея инкапсуляцию

Их опять же в ТС нет поэтому будем костылить. Кода там сильно больше поэтому вот плейграунд (он почему-то показывает ошибки местами, но у меня локально на 3.7.3 работает норм) typescriptlang.org/play/#code/PTA…

В чем прелесть? Прелесть в том что так мы инкапсулируем внутреннюю структуру нашего игрового поля (как в ООП), но при этом не получаем проблемы размазанного состояния (как в ООП) управляя им извне (как в ФП) При этом создать Game иначе кроме как через методы модуля game нельзя

В моем примере внешний пользователь вообще ничего не знает про Game, но естественно можно искапсулировать по частям type UserInternal = { id: number; username: string } export type User = Pick<UserInternal, 'username'> & { readonly __brand: unique symbol };

Да, писать довольно муторно и неудобно, но это просто потому что нет нативной поддержки Во flow это opaque (через который можно делать обычные номинальные типы) flow.org/en/docs/types/… В OCaml/ReasonML это делается через module

Вот такие у меня мысли по поводу того как писать на ТС Следующий тред будет про Reason и немного про другие языки