Добрый день, Хабр! Меня зовут Иван Колотилов и я фронтенд-разработчик. Я разрабатываю современные веб-приложения, специализируюсь на финансовых продуктах, работал в финтех-стартапах. Сегодня я хочу рассказать о том, как писать надёжный и расширяемый код с помощью TypeScript на примере разработки прикладного сервиса.
Иван Колотилов
Фронтенд разработчик в финтех-стартапе
Предположим, что у нас есть программа для редактирования видео, написанная на TypeScript. Мы описываем пакеты услуг через типы с опциональными полями, представляющими доступные пользователям варианты набора функций и объёма данных в зависимости от уровня подписки:
Для бесплатных аккаунтов — обозначим их условно как Free — возможность нарезки видео и добавления музыки.
Для платных с различной стоимостью — условно Premium и Platinum — возможность аннотаций, генерирования аудио из текста и набор других функций в зависимости от выбранного пакета.
Нам нужна страница, где будут отображены все эти планы и соответствующие им условия. Условия пользователя могут быть представлены в таком виде:
type Features = {
promotionId?: string,
subscriptionId: string,
uploadFileSizeLimit?: number
stockAudio?: boolean
autoSubtitlesLimit?: number
support: boolean,
supportChannel: string,
invitesLeft?: number
}
Получится объект с набором свойств, включающий в себя как обязательные, так и опциональные поля. Чтобы отобразить список доступных фич и лимиты на их использование, нам нужно будет проверять, есть ли каждое поле в отдельности. Появится запутанный, хрупкий и тяжело поддерживаемый код. При этом мы знаем, что на самом деле набор доступных фич и лимитов зависит от тарифа, который выбрал пользователь, а поле promotionId будет доступно только для бесплатного тарифа. В этом случае TypeScript не помогает отслеживать проблемы.
Но можно понять, какой именно набор функций доступен в каждом варианте, если у нас есть объект с большим числом опциональных полей.
Чтобы работать с планами и доступами к продукту, необходима проверка свойств.
const getFeaturesDescription = (features: Features) => {
if (features.uploadFileSizeLimit && features.promotionId) {
return `Upload file size limit: ${features.uploadFileSizeLimit}. PromotionId: ${features.promotionId}`
}
}
Если обрабатывать код по фичам, то для этого понадобится добавить в код много проверок, он будет громоздким и не выразительным, а также при расширении будет несложно допустить ошибку.
Возможным решением было бы полагаться на булевы переменные. Если мы захотим определить булевы переменные isFree / isPremium на основе наличия или отсутствия тех или иных свойств, при добавлении новых тарифов потребуются всё новые и новые переменные, и код также будет сложно поддерживать
Разработчику нужно видеть отчётливо, с каким именно планом он работает в коде, когда пишет логику для расценок. И тут на помощь приходят продвинутые возможности TypeScript. Их можно использовать для повышения надёжности и устойчивости кода.
Discriminating unions — дискриминантные объединения
Объединение (union) в TypeScript не является полноценным типом данных, но способно объединять в себе несколько типов в качестве одного типа. Также в TypeScript можно проанализировать наиболее вероятный тип значения в каждом случае. Уточнение типов до более конкретных возможных вариантов называется сужением — narrowing. Для этого используют дискриминантные объединения — discriminating unions.
Вернёмся к примеру. Пакеты услуг можно описать, используя Union как type Pricing = Free | Premium| Platinum с соответствующими строковыми переменными с различным набором свойств.
Это сокращает количество опциональных полей. Но возникает проблема: по умолчанию TypeScript не поймёт, какие поля у нас есть, а каких нет. Чтобы решить эту задачу, используем type narrowing с помощью discriminating union, определяя каждый пакет услуг как тип со своим набором полей и общим полем plan.
type FreeFeatures = {
plan: 'free',
promotionId?: string
support: boolean
uploadFileSizeLimit: number
}
type PremiumFeatures = {
plan: 'premium',
subscriptionId: string
stockAudio: boolean
autoSubtitlesLimit: number
support: boolean
supportChannel: string
}
type PlatinumFeatures = {
plan: 'platinum',
subscriptionId: string
stockAudio: boolean,
autoSubtitlesLimit: number
support: boolean
supportChannel: string
invitesLeft: number
}
Теперь, если мы проверим поле plan в switch конструкции, внутри этого блока кода Typescript будет понимать, какие поля доступны для каждого конкретного тарифа и будет сообщать нам об ошибке, когда мы хотим использовать отсутствующее поле.
Еще один распространённый случай использования дискриминантных объединений — это работа с данными сервиса с динамическими элементами: поиском или вводом данных. Уточнение и сужение при помощи discriminating unions помогают избежать ситуаций, когда результаты поиска или загрузки отображаются одновременно с информацией об ошибке.
При использовании этой технологии код становится читаемым и устойчивым, а компилятор TypeScript даёт более точные подсказки и лучше отлавливает ошибки.
Что почитать из теории Typescript, чтобы разобраться
Exhaustive checks — исчерпывающие проверки
Чтобы сделать код ещё более устойчивым, можно обработать все существующие виды пакетов услуг с помощью хелпера checkExhaustive с использованием типа never. Это позволит нам быть уверенными, что все возможные варианты этого типа (типы включённые в объединение) обработаны в нашем коде. Если мы добавим новый тип в объединение, например новый тариф UltraPremium, то Typescript выдаст ошибку, напоминая нам, что нужно написать логику для обработки этого типа тоже. В таких случаях в TypeScript используется тип never для указания на вариант, которого не должно быть.
Тип never может быть присвоен любому типу. Однако никакому типу нельзя присвоить значение never, кроме самого never. Это означает, что вы можете использовать сужение и полагаться на применение типа never в исчерпывающей проверке с конструкцией switch.
Вот так выглядит пример кода с проверкой на то, что все возможные тарифы в функции обрабатываются.
const getFeaturesDescrription = (features: Features) => {
switch (features.plan) {
case 'free':
return `Free plan. Upload size limit: ${features.uploadFileSizeLimit}`;
case 'premium':
return `Premium plan. Subscription Id: ${features.subscriptionId}`
case 'platinum':
return `Platinum plan. Invites left: ${features.invitesLeft}`;
default:
const unreachable: never = features
throw new Error('Plan not supported')
}
}
Проверку на то что наш код является исчерпывающим, можно также вынести в отдельную функцию.
function checkExhaustivness(x: never): never {
throw new Error("Should not happen");
}
Сопоставление с образцом — pattern matching
Ещё один способ заставить Typescript работать на максимуме возможностей, писать расширяемый код и минимизировать число ошибок — pattern matching, сопоставление с образцом. Эта концепция предполагает сопоставление параметров с существующим шаблоном, которое может сочетаться как с условным оператором, так и со switch. Если сопоставление осуществилось успешно, то выполняются действия, прописанные дальше в коде.
В некоторых языках, таких как F#, pattern matching включён по умолчанию. В JavaScript для его поддержки есть отдельный пропоузал, а в TypeScript — целый ряд популярных специализированных библиотек: ts-pattern, matchbook-ts, lil-match и match-toy.
Полноценное развёртывание этой логики позволяет делать больше, чем просто сопоставление с образцом. Pattern matching идеально подходит для случаев, когда у нас есть несколько вариантов одного и того же типа, как в примере с разными уровнями подписки на сервис. В этих случаях мы должны отдать приоритет эргономике сопоставления структурных шаблонов по сравнению с другими возможностями этой конструкции.
Представим, что нам нужно написать компонент, который определит, показывать ли интерфейс с продвинутой функциональностью, такой как автоматическая озвучка видео роботическим голосом пользователям.
Это зависит от тарифа пользователя и его роли. При этом существуют два вари анта этой функциональности: одна для тарифа Premium, а другая — для тарифа Platinum.
Если мы напишем логику, используя if, то она может выглядеть так.
export const VoiceOverPage = (plan: Plan, role: Role) => {
if ((plan === 'premium' || plan === 'platinum') && role === 'base') {
return <NoPermissions/>
}
if (plan === 'platinum' && role === 'admin') {
return <PlatinumVoiceOver/>
}
if (plan === 'premium' && role === 'admin') {
return <VoiceOver/>
}
if (plan === 'free') {
return <PlanUpgrade/>
}
}
Используя библиотеку для паттерн-матчинга ts-pattern, мы можем описать эту же логику в декларативном виде. Во многих случаях такой код будет проще расширять и поддерживать. Также библиотека позволяет проверить, исчерпывающий ли код — exhaustive checking — с помощью метода .exhaustive().
export const VoiceOverPage = (plan: Plan, role: Role) => {
return match([plan, role] as const)
.with(
['premium', 'base'],
['platinum', 'base'],
() => <NoPermissions/>
)
.with(['premium', 'admin'], () => <VoiceOver/>)
.with(['platinum', 'admin'], () => <PlatinumVoiceOver/>)
.with(['free', P._], () => <PlanUpgrade/>)
.exhaustive();
}
Разобраться с тонкостями Union & pattern matching в TypeScript человеку, знакомому с азами программирования, не составляет большого труда: впрочем, фронтенд-разработчику учиться приходится постоянно. Я пользуюсь TypeScript последние четыре года, освоил его по документации и кодам баз. TypeScript помогает писать устойчивый код, а знакомство с его расширенными возможностями позволяет брать больше подсказок от самого компилятора, меньше запоминать наизусть, то есть избегать ошибок уже на этапе написания кода.