Последнюю пару лет мой мозг программиста всё больше увлекался типами, принципами функционального программирования и Typescript. По большей мере на это повлияло огромное количество времени, потраченное мной на кодовую базу Heartbeat — фулстек-приложения из трёхсот тысяч строк на Typescript, включающего в себя веб-приложение React, мобильное приложение React Native и сервер Node.js. Мой опыт работы с этой кодовой базой показал мне, что чем больше я полагаюсь на систему типов, тем больше пользы из этого извлекаю.
Написание кода в кодовой базе, полностью сделавшей упор на типы, похоже на жульничество. Часто я могу реализовать 80% новой фичи, ни разу не запустив код. Я начинаю работать над крупным рефакторингом, требующим нарушить допущение, принятое во всём коде, но вскоре выясняю, что благодаря системе типов изменения оказываются тривиальными. Простые фичи практически кодируют себя сами, потому что опечатки мгновенно отлавливаются, а половина моего кода пишется автодополнением. На вопросы от команды техподдержки о тонкостях работы какой-то фичи можно ответить при помощи Ctrl+F в коде, даже если письменной документации почти нет. Целые категории багов, с которыми мне приходилось бороться, попросту исчезли.
Я начал называть стиль кодинга, позволяющий реализовать подобное, Type Driven Development. В статье я приведу разрозненные мысли и ссылки на ресурсы, сильно повлиявшие на то, как я понимаю type driven development.
▍ 1. Позвольте типам течь
Вложения в типобезопасность гораздо эффективнее, когда типы могут течь по каждой поверхности вашего приложения. В простом фулстек-приложении поток данных обычно выглядит так: база данных -> сервер -> клиент. При изменении схемы базы данных мы начнём встречать ошибки в коде фронтенда. Обеспечив подобный уровень взаимосвязанности типов, мы можем вносить изменения с одной стороны нашей кодовой базы и быть уверенными в том. что система типов сообщит нам о последствиях такого изменения, даже если последствия возникнут в совершенно другой части кодовой базы.
Под «потоком» типов по системе обычно подразумевается следующее:
- Использование одного и того же языка повсюду. Естественно, если мы хотим максимально обмениваться информацией о типах, то должны использовать один язык. React Native и Node.Js хоть и не идеальны, но достаточно мощны, чтобы позволить нам использовать Typescript на мобильных и на стороне сервера.
- Обеспечение сохранности информации о типах на границах сети. Использование ORM с качественной типизацией наподобие Prisma или Drizzle. Использование для вызовов API фреймворка с типизацией наподобие tRPC.
- Использование монорепозитория. Мы хотим, чтобы изменения в одной части системы предупреждали нас об изменениях, которые необходимо внести в другие части системы, а это невозможно, если другие части находятся в отдельном репозитории.
- Очень редкое применение
any
. Ничто не ломает поток типов сильнее, чемany
Часто это бывает сложно сделать. Для обеспечения потока типов требуется большой объём работ по проектированию системы так, чтобы обеспечивался полностью замкнутый цикл. При работе над Heartbeat мы приложили чрезвычайные усилия для того, чтобы этот поток был максимально надёжным. И иногда эта работа кажется бессмысленной, потому что я разгребаю загадочные ошибки Typescript вместо того, чтобы работать над новой фичей. Но для любой кодовой базы, которая будет использоваться долгое время, создание хорошей базовой инфраструктуры приносит огромную пользу.
▍ 2. Начинайте с типов
Учитывая, что типы — это клей, связывающий систему, начинать создание новой фичи следует с определений типов. Первым делом при реализации новой фичи я составляю перечень всех задействованных сущностей, их взаимосвязей, свойств и того, как они должны быть представлены в виде типов.
В этом видео показан прекрасный пример концепции.
Самые важные выводы из него:
a) Определения типов — отличный способ проверки того, что моё понимание предметной области согласуется с реальностью. Процесс написания этих определений типов заставляет меня исчерпывающим образом перечислять и продумывать различные компоненты проекта. Обычно этот процесс приводит к обнаружению пробелов или неопределённостей в спецификации фичи, которыми я могу поделиться с командой. Часто такие пробелы/неопределённости в противном случае оставались бы ненайденными и обнаруживались лишь на середине проекта. Возможно, на этом этапе для их устранения пришлось бы переделывать всё выполненную мной работу. Благодаря type driven development подобные вопросы возникают в самом начале процесса и на них даются ответы, которые служат чертежом для остальной части проекта.
б) Определения типов человекочитаемы. Для новичка в проекте (или для меня в будущем) это отличный способ понять, как структурирована фича в целом без необходимости вдаваться в подробности самого кода.
в) Определения типов — это и документация модели предметной области, и реальный код, непосредственно используемый в нашей реализации. Это живой документ, гарантировано являющийся точным отражением кодовой базы.
г) Концептуальная идея сущности важнее, чем её представление. Например, и адрес электронной почты, и id клиента представлены в виде строк, но концептуально это независимые сущности, которые не должны считаться одним типом. Такое разделение должно отражаться в наших типах.
▍ 3. Сделайте так, чтобы недопустимые состояния невозможно было представить
Отдельного рассмотрения требует ещё один ключевой аспект видео: если наши типы будут максимально близко отражать истинную природу данных, то в них практически невозможно будет допустить ошибки. Вот несколько примеров того, как это может выглядеть в Typescript:
- У нас есть сущность
Product
. У каждого продукта должна быть хотя бы одна цена:
type NonEmptyArray<T> = [T, ...T[]]; type Price = //something interface Product { //... prices: NonEmptyArray<Price>; //... } function createProduct(product: Product) { //... } //Если я попытаюсь создать продукт без цен, то получу ошибку типа createProduct({ prices: [], })
- Наши пользователи опционально могут решить указать свой адрес:
//Плохая реализация type User = { //... addressLine1?: string; addressLine2?: string; city?: string; state?: string; country?: string; //... }; function createUser(user: User) { //... } //Я могу создать пользователя и забыть включить части его адреса createUser({ //... addressLine1: "123 Example St", //... }); /*-------------------------------------------*/ //Хорошая реализация type User = { //... address: { line1: string; line2: string; city: string; state: string; country: string; } | null; //... }; //Система типов гарантирует, что если мы решили указать данные, то укажем их все createUser({ //... address: { line1: "123 Example St", line2: "Apt 1D", city: "Seattle", state: "Washington", country: "USA", }, //... });
Подобное структурирование типов устраняет возможность возникновения целой категории багов. Каждое отдельное состояние, которое мы делаем недопустимым при помощи системы типов — это баг, который ждал бы своего часа в альтернативной вселенной. Кроме того, ценен и сам процесс превращения недопустимых состояний в непредставляемые. Если мы делаем так, чтобы типы максимально близко отражали «истину», то это вынуждает нас задавать множество вопросов о том, что же такое истина:
- Существуют ли случаи, в которых продукту допускается не иметь цен?
- Сколько существует возможных состояний? Действительно ли различаются состояния X и Y, или они, по сути, являются одним и тем же?
И эти вопросы крайне важны для понимания полноты задачи, которую мы стремимся решить.
▍ 4. Парсите, а не валидируйте
Прочтение этой статьи позволило мне чётко сформулировать столько расплывчатых мыслей, что я превратил их в простой слоган.
Основной вывод из статьи заключается в том, что типы можно интерпретировать как средство для «хранения» валидации. Если мы закодируем процесс валидации в типах, то:
- остальная часть кода сможет делать допущения о данных без необходимости постоянной валидации этих допущений
- система типов заставляет эту валидацию выполняться при необходимости
- менять эти допущения становится намного проще. Мы можем менять типы так, чтобы они отражали новое допущение, и система типов будет указывать нам на последствия таких изменений
(Этот раздел оказался таким коротким только потому, что в статье всё замечательным образом всё объяснено. Обязательно прочитайте её!)
▍ 5. Будьте честными
Я пришёл к тому, что во многих смыслах воспринимаю программирование как поиск истины. Моя цель заключается в нахождении чистейшей, необработанной, глубочайшей сути того, что представляет собой сущность, и в выражении этого в виде типа. А если моя цель — истина, то я должен ценить честность моего кода. Она может проявляться в принципе «сделайте так, чтобы недопустимые состояния невозможно было представить» — я не хочу лгать и утверждать возможность чего-то, что невозможно. Или если я углублюсь в анализ и обнаружу, что две вещи, которые мне казались одинаковыми, на самом деле разные, то я не должен лгать и обращаться к ним как к одному типу. Мне нужно дополнительно потрудиться, чтобы разделить их на разные типы, потому что это будет честным отражением истины.
Для контраста с этим грандиозным описанием приведу повседневный пример. У нас есть приложение-календарь, в котором пользователи могут создавать события и просматривать список своих событий:
type Event = {
id: EventId;
title: string;
description: string;
startTime: Date;
endTime: Date;
duration: number;
}
function EventList(props: { events: Event[] }) {
return (
<div>
<h1>My Events</h1>
{props.events.map((ev) => (
<EventListItem event={ev} />
))}
</div>
)
}
function EventListItem(props: { event: Event }) {
return (
<div>
<h3><font color="#3AC1EF">▍ {props.event.name}</font></h3>
<p>{props.event.description}</p>
<p>Start: {props.event.startTime}</p>
<p>End: {props.event.endTime}</p>
</div>
)
}
Мы решаем добавить новую фичу. Теперь пользователи могут создавать повторяющиеся события! Они определяют повторяющееся событие при помощи правила повторения, которое описывает, когда и как часто повторяется событие. Мы хотим, чтобы в списке событий отображались все экземпляры будущих повторяющихся событий. Для этого мы создаём функцию
getExpandedEvent
, получающую событие. Если событие повторяющееся, то событие расширяется, чтобы включить в себя все экземпляры. В противном случае мы возвращаем единичное событие. После создания этой функции нам достаточно просто вызвать её в компоненте EventList
, после чего можно продолжать обычное выполнение программы.type Event = {
id: EventId;
title: string;
description: string;
startTime: Date;
endTime: Date;
duration: number;
//Добавлено новое поле
recurrenceRule: string | null;
}
function getRecurringDates(startTime: Date, recurrenceRule: string): Date[] {
//вычисляем все повторяющиеся даты согласно правилу повторений
}
function getExpandedEvent(event: Event): Event[] {
if (event.recurrenceRule !== null) {
const recurringDates = getRecurringDates(
event.startTime,
event.recurrenceRule
)
return recurringDates.map((date) => ({
...event,
startTime: date,
}))
} else {
return [event]
}
}
function EventList(props: { events: Event[] }) {
const expandedEvents = getExpandedEvent(props.events).flat()
return (
<div>
<h1>My Events</h1>
{expandedEvents.map((ev) => (
<EventListItem event={ev} />
))}
</div>
)
}
Это отлично работает. Пользователи без проблем могут видеть все свои повторяющиеся события. Месяц спустя мы наконец решаем добавить кнопку, позволяющую пользователям удалять события. Делаем мы это примерно так:
function EventListItem(props: { event: Event }) {
//Если вы незнакомы с tRPC, то вам достаточно знать, что deleteEvent.mutateAsync - это функция, выполняющая API-запрос к нашему серверу. На сервере мы удаляем событие
const deleteEvent = trpc.deleteEvent.useMutation()
return (
<div>
<h3><font color="#3AC1EF">▍ {props.event.name}</font></h3>
<p>{props.event.description}</p>
<p>Start: {props.event.startTime}</p>
<p>End: {props.event.endTime}</p>
<button onClick={() => deleteEvent.mutateAsync(props.event.id)}>Delete</button>
</div>
)
}
Выглядит довольно просто, поэтому мы релизим фичу. Вскоре начинают поступать баг-репорты от клиентов: «Я пытался удалить событие за май в моём ежедневном событии, но почему-то удалилось событие целиком!» Как вы могли заметить, проблема этой реализации заключается в том, что при нажатии на удаление одного экземпляра повторяющегося события удалится событие целиком. Не совсем то поведение, которое нам нужно. Нам нужно разобраться с парой неправильных моментов:
- Тот, кто писал спецификацию фичи удаления события, должен был определить, как она должна работать для неповторяющихся и повторяющихся событий
- Вероятно, мы бы поняли, что это странно, если бы перед релизом провели больше тестов или QA
И хотя это может быть правдой, я бы сказал, что одна из фундаментальных причин этой проблемы в том, что мы не были полностью честны при реализации функции
getExpandedEvent
. В то время нам было удобно продолжать использовать тип Event
. Нам достаточно лишь вызвать функцию, а всё прочее останется таким же. Но если бы мы были честны, то увидели бы, что, несмотря на свою схожесть, события и экземпляры событий — это разные концепции.Event
— это базовый объект, хранящийся в базе данных. Когда пользователь создаёт повторяющееся событие, допустим, совещание в каждый понедельник в течение года, то мы не сохраняем в базе данных 52 отдельных события. Вместо этого мы храним одно Event
с правилом повторения. Когда кому-то нужно просмотреть ближайшие события, мы используем это правило для генерации соответствующих EventInstance
, каждый из который обозначает одно конкретное совещание. События хранятся в базе данных, а экземпляры событий эфемерны. События можно создавать, а экземпляры — нет. Редактирование Event
(перенос совещания с 14 часов понедельника на 15 часов) — это действие, совершенно отдельное от редактирования EventInstance
(переноса только одного конкретного расписания с понедельника на вторник). Более честное представление может выглядеть так:type EventInstance = Omit<Event, "id"> & {
id: EventInstanceId;
eventId: EventId;
};
function getEventInstanceId(eventId: EventId, startTime: Date) {
return `${eventId}-${startTime.toISOString()}` as EventInstanceId;
}
function getEventInstances(event: Event): EventInstance[] {
if (event.recurrenceRule !== null) {
const recurringDates = getRecurringDates(event.startTime, event.recurrenceRule);
return recurringDates.map((date) => ({
...event,
startTime: date,
id: getEventInstanceId(event.id, date),
eventId: event.id,
}));
} else {
return [
{
...event,
id: getEventInstanceId(event.id, event.startTime),
eventId: event.id,
},
];
}
}
function EventListItem(props: { eventInstance: EventInstance }) {
//Рендерим экземпляр события
}
Конкретная реализация
EventInstance
варьируется в зависимости от нужного нам поведения. Но самое главное здесь — отделение экземпляров событий от событий. Если бы мы сделали это, то ни за что бы не столкнулись с проблемами удаления, потому что нам бы было понятно, что конечная точка deleteEvent
неприменима к экземплярам событий. На самом деле, если вернуться к разделу «Начинайте с типов», то тип EventInstance
в нашей кодовой базе, вероятно, заставил бы нас осознать на этапе создания спецификации фичи, что нам нужно отдельно обрабатывать удаление событий и экземпляров событий.Легко понять, почему нам не захотелось вносить это изменение. Оно требует дополнительной работы, но не приносит выгоды напрямую. Добавление нового типа потребует и изменения
EventListItem
. В большом проекте новый тип может потребовать ещё большего рефакторинга во всей кодовой базе. Но впервые поленившись, мы впустили в нашу систему типов небольшую ложь, испортившую её и заставившую нас немного отклониться от истинного представления лежащей в основе предметной области. Из-за этого типы не смогли помочь нам, когда они понадобились нам позже.▍ 6. Будьте конкретными
Напарник честности — конкретность. Мы не хотим лгать, умалчивая о чём-то. Поэтому мы максимально стремимся к тому, чтобы наши типы были самым узким выражением истины.
Branded types — отличный пример этого. Мы могли бы использовать
string
для представления id пользователя, и это было бы честным представлением. Но если бы мы использовали вместо неё branded type UserID
, то ещё конкретнее описали то, что на самом деле представляет id пользователя. И чем конкретнее мы будем, тем больше нам поможет модуль контроля типов:type CommunityID = string & { readonly _: "__CommunityID__" };
type UserID = string & { readonly _: "__UserID__" };
type PostID = string & { readonly _: "__PostID__" };
interface Post {
id: PostID;
createdBy: UserID;
communityID: CommunityID;
//...
}
function getIsUserAdmin(userID: UserID) {
//...
}
function getShouldShowPost(post: Post) {
//Если мы случайно вызовем эту функции с не тем id, то получим ошибку типа
const isAdmin = getIsUserAdmin(post.communityID);
const isAdmin = getIsUserAdmin(post.createdBy);
//...
}
▍ 7. Чистые функции как мостики между типами
Как только вы начнёте видеть всё в своей кодовой базе через объектив типов, то любое действие пользователя можно будет свести к последовательности переходов между типами. Определяем начальные типы. Определяем конечные типы. Находим способ дойти от начальных к конечным типам. Каким будет наилучший способ преобразовать один тип в другой без лишних помех? Чистая функция. Это практически буквальное математическое определение функции.
Допустим, мы реализуем страницу оплаты. Наша предметная область выглядит так:
type Price = {
id: PriceId;
amount: number;
interval: "month" | "year";
};
type Product = {
id: ProductId;
name: string;
prices: NonEmptyArray<Price>;
};
type Subscription = {
id: SubscriptionId;
status: "active" | "canceled";
productId: ProductId;
priceId: PriceId;
};
type User = {
id: UserId;
subscriptions: Subscription[];
};
На странице оплаты есть три возможных варианта:
- Пользователь пока не купил продукт; он может выбрать вариант цены и совершить покупку
- Пользователь уже оформил подписку; мы отображаем кнопку «Отменить подписку». Когда пользователь нажимает на эту кнопку:
- Если он был подписчиком больше 12 месяцев, то предлагаем ему скидку в 1% за каждый месяц этого срока (не больше 50%)
- В противном случае предлагаем ему скидку в $5
- Пользователь отменил подписку; в этом случае мы спрашиваем, хочет ли он возобновить подписку
Представив это в виде типа, мы получим:
type Discount =
| {
type: "PERCENTAGE";
percentage: number;
}
| {
type: "FLAT_AMOUNT";
amount: number;
};
type CheckoutPageState =
| {
type: "INITIAL_PURCHASE";
product: Product;
}
| {
type: "CANCELED";
subscriptionId: SubscriptionId;
}
| {
type: "ALREADY_SUBSCRIBED";
product: Product;
cancelationDiscount: Discount;
};
Пользователь переходит на страницу оплаты конкретного продукта. Нам нужно отрендерить страницу. Как это сделать? Если мы определили эти типы, то текущая задача ясна. Нужно преобразовать
Product
и User
в CheckoutPageState
. Если у пользователя есть активная подписка, то нужно преобразовать Subscription
в Discount
. Поэтому мы пишем две чистые функции для выполнения преобразования, и на этом всё.//Теперь мы выполняем передачу в виде параметра, чтобы сделать функцию по-настоящему чистой
//Это упрощает тестирование функций
function getDiscount(subscription: Subscription, now: Date): Discount {
const numMonths = differenceInMonths(now, subscription.createdAt);
if (numMonths > 12) {
return {
type: "PERCENTAGE",
percentage: Math.min(50, numMonths),
};
} else {
return {
type: "FLAT_AMOUNT",
amount: 5,
};
}
}
function getCheckoutPageState(product: Product, user: User, now: Date): CheckoutPageState {
const existingSubscription = user.subscriptions.find((x) => x.productId === product.id);
if (existingSubscription !== undefined) {
if (existingSubscription.status === "canceled") {
return {
type: "CANCELED",
subscriptionId: existingSubscription.id,
};
} else if (existingSubscription.status === "active") {
const discount = getDiscount(existingSubscription, now);
return {
type: "ALREADY_SUBSCRIBED",
product: product,
cancelationDiscount: discount,
};
} else {
assertNever(existingSubscription.status);
}
} else {
return {
type: "INITIAL_PURCHASE",
product: product,
};
}
}
//***** CheckoutPage.tsx *****\\
function CheckoutPage(props: CheckoutPageState) {
if (props.type === "INITIAL_PURCHASE") {
//рендерим initial purchase
} else if (props.type === "ALREADY_SUBSCRIBED") {
//рендерим already subscribed
} else if (props.type === "CANCELED") {
//рендерим canceled
} else {
safeAssertNever(props.type);
return null;
}
}
Надеюсь, вы понимаете, как этот небольшой пример можно расширить до приложения в продакшене с десятками типов и сотнями правил бизнес-логики, закодированными в сеть чистых функций. Магия чистых функций заключается в том, что они превращают нечто пугающее в то, с чем уже можно иметь дело. У нас может быть самая корявая и сложная предметная область с кучей пересекающихся пограничных случаев и неинтуитивной логикой, но если мы разобьём её на составляющие её типы, то в любой конкретный момент времени нам не придётся беспокоиться о преобразовании
IntermediateType5
в IntermediateType6
. И нам не придётся думать ни о чём другом. Такой уровень чёткости и сосредоточенности творит чудеса, снижая мою когнитивную нагрузку при работе над крупными проектами.Для фулстек-приложения паттерн прост:
- Определяю типы для моих базовых сущностей, то есть тех, которые будут храниться в базе данных (
Product
,Price
,User
,Subscription
) - Определяю мои промежуточные типы (
Discount
,CheckoutPageState
) - Получаю релевантные базовые сущности из базы данных
- Пропускаю сущности через последовательность чистых функций, переходя от типа к типу, пока у меня не будет UI для отображения
Даже React, последний кусок пазла в этом примере, построен на принципах функционального программирования. Наш компонент React
CheckoutPage
— это та последняя чистая функция, преобразующая тип CheckoutPageState
в JSX, который рендерится на экране.▍ 8. Просите, и вам воздастся
Одно из самых частых изменений, требуемых для новый фичи, заключается в том, что нечто, бывшее простым, становится лишь немного более специфичным. До фичи мы вызывали функцию и она выполняла это нечто. Но теперь этой функции требуется чуть больше контекста. И на основании этого контекста она делает что-то немного отличающееся. Допустим, у нас есть функция
generateInvoice
, получающая различные значения и использующая их для генерации объекта Invoice
. Подробности самой функции нам не важны.function generateInvoice(params: {
customerId: string;
currency: string;
items: InvoiceItem[];
//...
}): Invoice {
//Генерируем счёт
}
Но теперь наконец настало время, когда мы должны перестать игнорировать письма налоговой и начать правильно взимать налоги с пользователей. Для этого внешний вид наших счетов должен зависеть от ставки налогов.
При использовании типов мой подход к изменениям будет очень простым. Я перехожу к функции, которая требует дополнительного контекста, и изменяю входной тип, чтобы включить в него новый необходимый мне контекст. А затем я смотрю на возникающие ошибки типов. Если функция, вызывающая
generateInvoice
, не знает, какой должна быть taxRate
, то я добавляю во входные данные этой функции taxRate
и продолжаю выполнение вверх по стеку вызовов. Рано или поздно я достигну функции, способной подтянуть необходимый контекст из хранящего состояние источника (базы данных, конечной точки и так далее), ИЛИ смогу вычислить необходимое значение и передать его.Допустим, в этом случае я добавлю к
params
поле taxRate
и увижу следующие две ошибки:- Одна происходит в бэкенде, когда мы вызываем
generateInvoice
изgenerateInvoiceForCustomer
. Чтобы устранить её, я вызываюcalculateTaxRateForCustomer
внутриgenerateInvoiceForCustomer
и передаю результат этой функции вgenerateInvoice
. - Вторая происходит во фронтенте при вызове
generateInvoice
изpreviewInvoice
. В этом случае мы генерируем предварительный счёт для образца покупателя, поэтому ставка налога не должна вычисляться. Вместо неё я просто передаю статическое значение0.1
, которое будет использоваться как пример ставки налога.
Такой подход экономит много времени и избавляет от головной боли. В большом приложении может оказаться так, что
generateInvoice
вызывается из семи различных контекстов. Вместо того, чтобы проактивно пытаться сопоставить все эти разнообразные случаи, я просто сообщаю системе типов, что мне нужно определённое значение и позволяю ей направлять меня туда, куда я должен пойти дальше.▍ 9. Если код компилируется, то он работает
У разработчиков на Haskell есть поговорка: если код компилируется, то он, скорее всего, работает. Хоть Haskell и находится на самом краю спектра, оказалось, что это вполне справедливо для любой системы, активно использующей типы. Если в вашу кодовую базу встроены типы, то цикл обратной связи в вашем коде возникает напрямую из редактора и ошибок типов. Мне не нужно запускать код, чтобы понять, что я допустил оплошность, потому что редактор сам скажет мне об этом в процессе ввода.
Существует обратная зависимость между типами и количеством возможных программ, успешно выполняющих проверку типов. Поэтому если ваши типы честны и конкретны, то будет сложно писать код с успешной проверкой типов, который не может делать то, что мы от него хотим.
Лучший пример этого — повышение моей продуктивности в самолёте. Несмотря на то, что я работаю над фулстек-приложением, требующим для локальной работы Интернета, я сажусь на борт без WiFi, достаю ноутбук и без малейших помех реализую новую фичу Heartbeat — достаточно лишь меня и моего модуля проверки типов. Когда я приземляюсь и запускаю код, после небольшой работы над UI он обычно выполняется без проблем; иногда требуется всего 2-3 небольших исправления багов.
▍ 10. Типы как инструмент интроспекции
Один из самых недооценённых аспектов Typescript заключается в том, что его можно использовать как инструмент для познания кодовой базы. Если мне нужно понять, как работает конкретная фича, я могу поиграться с типами, которые помогут мне понять, как части этой фичи взаимодействуют с остальной частью кодовой базы.
Допустим, я работаю над апдейтом, в котором хочу стандартизировать способ отображения цен на продукты. Пока мы поддерживаем единственную валюту (USD), а все наши цены выглядят так: $100.00. Но некоторые пользователи из Канады путаются, имеем ли мы в виду USD или CAD, поэтому мы хотим изменить UI, чтобы в нём чётко говорилось, что имеются в виду USD. Мы отображаем цены во множестве разных компонентов в различных контекстах, поэтому отследить всё это будет проблематично.
Я могу временно удалить из типа
Price
поле amount
:type Price = {
id: PriceId;
interval: "month" | "year";
};
Теперь везде, где я попытаюсь сослаться на поле
amount
, будет возникать ошибка типа. Поэтому если у меня есть десять компонентов, каким-то образом отображающих цену, Typescript укажет мне на каждый из этих компонентов. Я могу перейти к каждому из них, внести нужные изменения и откатить изменение, внесённое в тип.Или, допустим, у меня есть компонент
Button
в системе дизайна, и мы думаем избавиться от варианта success
, потому что он кажется ненужным. Чтобы определиться с этим решением, нам нужно выявить все экраны продукта, на которых используется кнопка success
.interface Props {
//...
variant?: "primary" | "secondary" | "success";
//...
}
function Button(props: Props) {
//кнопка
}
Я мог бы поискать при помощи Ctrl-F
success
, чтобы попытаться найти все вхождения таким образом, но, разумеется, я получу множество несвязанных с этой кнопкой результатов. В таких ситуациях я просто удаляю success
как одну из опций в Props
. После этого я сразу получаю ошибки типов, указывающие мне на конкретные места нахождения кнопок success
в продукте. Далее я могу понажимать на результаты и оценить, необходима ли кнопка success
, или её можно заменить альтернативой. Возможность быстро отвечать на вопросы типа «Где в нашем продукте находится каждая кнопка success?» позволяет мне даже использовать кодовую базу в качестве инструмента на совещаниях по планированию дизайна/продукта.▍ 11. Сложный и простой режимы
Широкое применение типов разбивает кодинг на две фазы: короткий период напряжённой и сложной работы, за которым следует более долгий период простой работы, в которой трудно допустить ошибку.
Сложная часть заключается в подготовке скаффолдинга и определении типов. В Typescript скаффолдинг может включать в себя погружение в запутанное программирование на уровне типов или создание сложных фреймворков для обеспечения надёжного распространения типов (type propagation). Как мы говорили выше, для определения типов нужно глубоко разбираться в том, какие состояния валидны, как быть честными, где проводить границу между сущностями и так далее. Анализ всего этого может быть пугающим и муторным процессом.
Но упорный труд вознаграждается, потому что кодинг с типами невероятно ПРОСТ. Редактор постоянно направляет вас по правильному пути. Опечатки, ошибочно используемые переменные, забытые проверки валидации мгновенно выявляются. Благодаря автодополнению печатать приходится гораздо меньше. В эпоху автодополнения кода на основе ИИ определения типов предоставляют ценный источник документации, помогающий ИИ предлагать более точные рекомендации. А модуль проверки типов мешает оставаться незамеченным недопустимому коду, предложенному ИИ.
Такая дихотомия хорошо соответствует моему образу мышления. Я способен направлять кратковременные вспышки творческой энергии на отражение предметной области или подготовку скаффолдинга типов. А после этого я могу выдерживать долгие сессии кодинга по реализации самой фичи, потому что благодаря скаффолдингу мне не приходится особо сильно задумываться.
▍ 12. Знайте, когда остановиться
Кому-то раздел может показаться спорным, но лично мне очень нравится, что Typescript позволяет нам время от времени жульничать. Важно понимать, когда стоит сдаться и использовать
any
. Если мы будем рассудительными, то сможем сохранить 99,9% преимуществ системы типов, не тратя дни на покрытие оставшихся 0,1%.Обычно использование
any
или утверждений типов (type assertion) наподобие as string
— плохая идея, потому что из-за них мы лжём системе типов. Но в ситуациях, когда нам точно виднее, чем системе типов, а диапазон нашего утверждения мал, то небольшая ложь приемлема. Частый пример того, когда нам виднее, чем системе типов — работа с внешними зависимостями или легаси-кодом. Например, вот вспомогательная функция, которую мы используем для получения флагов фич из Posthog:export const POSTHOG_FLAGS = {
"longer-free-trial": ["control", "30-days"],
"checkout-page-design": ["control", "variant-a", "variant-b"],
} as const;
export type PosthogFlag = keyof typeof POSTHOG_FLAGS;
export async function getPosthogFlagVariant<T extends PosthogFlag>(userID: UserID, flagName: T) {
const variant = await posthog.getFeatureFlag(flagName, userID);
return variant as (typeof POSTHOG_FLAGS)[T][number] | undefined;
}
Возвращаемый тип для
posthog.getFeatureFlag
по умолчанию — string | boolean | undefined
. Но мы обладаем более точным знанием. Если мы получаем значение для longer-free-trial
, то возвращаемый тип должен быть или control
, или 30-days
. Поэтому мы можем использовать утверждение типа, чтобы заявить Typescript о нашем знании. И благодаря этому все, кто вызывает getPosthogFlagVariant
, будут получать более точные и конкретные типы. Так как POSTHOG_FLAGS
редактируются в сильно контролируемом контексте (только в случае добавления или изменения флага фичи), мы можем быть уверены в том, что несвязанные с этим изменения в кодовой базе вряд ли приведут к негативным последствиям этой лжи.Полезно также знать, когда стоит нарушать правило «Сделайте так, чтобы недопустимые состояния невозможно было представить». Иногда случается так, что трудозатраты, необходимые для того, чтобы сделать недопустимое состояние полностью непредставимым, просто того не стоят. А если я знаю, что в будущем возможны изменения в том, что является допустимым состоянием, то могу и не хотеть, чтобы мы слишком жёстко отсекали все варианты. Выработать понимание того, когда стоит отказываться от правил, бывает сложно и в основном оно приходит с опытом. Отличный пример этого — preemptive pluralization.
В конечном итоге, важно помнить, что все эти правила нужны для того, чтобы повышать нашу продуктивность. Мы должны пользоваться инструментами, а не инструменты должны использовать нас.
Telegram-канал со скидками, розыгрышами призов и новостями IT ?
Комментарии (7)
kinall
02.01.2025 03:15Название прямо как из моего первого учебника Паскаля 2002-го года издания)
Содержание, впрочем, в целом тоже оттуда. Что говорит нам о вечных ценностях программирования =))
muhachev
02.01.2025 03:15Похоже на краткий пересказ классики OOA&D без использования UML в контексте популяризации TS.
redfox0
02.01.2025 03:15В статье говорится про js, но и python в последнее время движется в сторону статических анализаторов кода большими шагами. И добавление аннотации типов даже сторонних библиотек.
eugenk
02.01.2025 03:15Честно говоря вообще не представляю себе, как что-то серьёзное может быть написано без системы типов. Хотя народ пишет вполне серьезные вещи и на питоне и на джаваскрипте. И наоборот ругается, что система типов сковывает свободу. Наверно просто мозги у всех устроены по-разному.
vkni
02.01.2025 03:15Путём чудовищного падения производительности, естественно. Если вбросить огромное количество людей, которые будут писать тесты, то можно. Так программисты дёшевы.
Anarchist
Редкостно хорошая статья на фоне шлака, которым заполняется хабр в последнее время. Спасибо.