TypeScript не медленный — преусложнённые типы являются причиной.
Универсальные супы, преждевременное использование механизма DRY для различных типов, состояние опционального флага и излишняя сложность на уровне типов снижают скорость разработки. Лучше использовать простые интерфейсы, дискриминированные союзы и тщательно оценивать затраты, связанные с инструментом tsc.
Как общие решения, гимнастика с использованием условий и преждевременная абстракция снижают скорость выполнения задач.
На встрече по анализу спринта младший инженер признается, что добавление одного необязательного поля в существующий пакет данных API заняло четыре часа. Интегрированная среда разработки показывает причину: интерфейс, построенный из вложенных условий.
type ExtractNestedPayload<
T,
K extends keyof T,
U extends boolean = false
> =
T[K] extends (...args: any[]) => infer R
? R extends Promise<infer P>
? U extends true ? NonNullable<P> : P
: R
: T[K] extends Array<infer Item>
? Item
: never;
Четыре уровня вложенных условий, три общих параметра и цепочка троичных операций, которая требует использования доски для записей. Ошибки компилятора появляются в виде длинных красных подсказок:
Type 'ExtractNestedPayload<ApiResponses["users"], "data", true>'
is not assignable to type 'UserDTO'.
Types of property 'meta' are incompatible.Type 'unknown' is not assignable to type
'{ pagination: PaginationMeta }'.
Всегда встречающаяся жалоба звучит так: сам TypeScript замедляет работу команды. Обычно это не так — проблема в чрезмерно сложных абстракциях. TypeScript был создан как прагматичный инструмент: для снижения количества сбоев во время выполнения и улучшения автодополнения кода. Однако со временем многие проекты превратили его в источник сложностей: разработчики тратят часы на создание математически «идеальных» генериков лишь для того, чтобы избежать нескольких строк простых объявлений. Чем более сложной становится структура типов, тем меньше она помогает людям, отвечающим за реализацию функций. Тип должен передавать намерение автора кода; если для его понимания требуются условные типы, особенности автоматического вывода, преобразованные типы, дистрибутивные объединения, рекурсивные генерики и целый набор вспомогательных функций, намерение теряется, и приходится отлаживать совершенно другую систему. Ниже приведены паттерны, которые имеют обратный эффект в производственных условиях, и более простые альтернативы, восстанавливающие скорость разработки.
1. Антипаттерн «суп из генериков»
Генерики лежат в основе типов Promise<T>, Array<T> и Map<K, V>. Проблемы возникают тогда, когда гибкость становится признаком высокого уровня квалификации. Большее количество генериков не обязательно означает лучшую производительность. Возьмем, к примеру, компонент таблицы:
// The Generic Soup Nightmare
interface TableProps<
TData,
TKey extends keyof TData,
TColumn extends ColumnDef<TData, any>,
TFilter extends Record<string, any> = Record<string, any>
> {
data: TData[];
keyExtractor: (item: TData) => TData[TKey];
columns: TColumn[];
initialFilter?: TFilter;
onRowClick?: (row: TData) => void;
}
Кажется, что его можно использовать бесконечно часто: структура данных, ключи строк, столбцы, фильтры. Каждая абстракция сопряжена с когнитивными затратами. Места вызовов заставляют компилятор выводить несколько взаимосвязанных параметров. Небольшое несоответствие свойств может указывать не на конкретное проблемное свойство; выводы могут быть неверными для всей структуры. Новый коллега должен понять, зачем существует TKey, почему он наследуется от keyof TData, как взаимодействуют генерики столбцов и фильтров, а также что на самом деле выводил компилятор. Это серьезная нагрузка для простого компонента таблицы.
Это налогообложение приводит к более медленному рассмотрению кода, длительнейшему процессу адаптации сотрудников и культуре, в которой только один-два человека осмеливаются изменять общедоступные элементы интерфейса. Снижение скорости работы связано как с техническими, так и с социальными факторами: коллеги перестают предлагать небольшие улучшения из-за страха перед последствиями. Когда для абстракции таблицы требуется документ с описанием, чтобы объяснить её генерики, это означает, что абстракция вышла за рамки проблемы, для решения которой она была создана.
Симптомы в продакшене скучны и дорогостоящи. Простое переименование одной переменной запускает диагностику, в которой упоминаются не связанные параметры типов. Функция автодополнения тормозит, пока сервис языка переоценивает структуру генериков. Время проверки типов в системе CI постепенно увеличивается, при этом никто не создаёт более чёткую модель домена. Ни один из этих расходов не отражается в сравнении «TypeScript против JavaScript»; они проявляются в количестве потраченного времени.
Конкретная альтернатива
Лучше выбирать один параметр данных и простые вспомогательные интерфейсы:
// Simple, readable, and instant compiler diagnostics
interface TableColumn<T> {
header: string;
accessor: (item: T) => React.ReactNode;
width?: string;
}
interface DataTableProps<T> {
data: T[];
columns: TableColumn<T>[];
rowKey: (item: T) => string;
}
Компонент остается переиспользуемым, не превращаясь в источник типов. Переиспользуемость не означает бесконечную универсальность.
Переиспользуемость не означает бесконечную универсальность.
Команды иногда боятся, что упрощение концепции универсальных компонентов приведёт к необходимости копирования и вставки кода. На практике два-три специализированных варианта таблиц с чётко определёнными свойствами лучше, чем один универсальный компонент, который невозможно использовать без проб и ошибок. Делитесь инструментами отрисовки и CSS; оставляйте публичные свойства простыми. Тогда компилятор будет указывать на конкретное несоответствующее поле, вместо того чтобы сливать четыре переменные вывода в неопределённую ситуацию.
2. Преждевременное применение принципа DRY в определениях типов
Принцип «Не повторяйтесь» полезен для кода во время выполнения, но опасен при слепом применении к типам. Увидев похожие поля, команды создают один тип на основе другого:
// Over-abstracted type derivation
type RegisterFormValues =
Omit<
UserProfile,
'id' | 'createdAt' | 'updatedAt' | 'role'
> & {
passwordConfirmation: string;
termsAccepted: boolean;
};
Позже сущность меняется:
interface UserProfile {
// ...
phoneNumber: string; // now required!
}
Полученный тип формы тихо наследует обязательное поле phoneNumber, которое совсем не требовалось при регистрации. Это приводит к дополнительным корректировкам:
type RegisterFormValues =
Omit<
UserProfile,
'id' |
'createdAt' |
'updatedAt' |
'role' |
'phoneNumber'
> & {
phoneNumber?: string;
passwordConfirmation: string;
termsAccepted: boolean;
};
Каждое упущение усугубляет неопределенность. Форма и сущность в базе данных меняются по разным причинам; их связывание вызывает неожиданные сбои.
Дублирование дешевле, чем неправильная абстракция
Опишите контракты отдельно:
// Database Entity Contract
export interface UserProfile {
id: string;
email: string;
fullName: string;
phoneNumber: string;
createdAt: string;
}
// Registration Form Contract
export interface RegisterFormValues {
email: string;
fullName: string;
phoneNumber?: string;
password: string;
passwordConfirmation: string;
termsAccepted: boolean;
}
Несколько дублированных полей стоят дешевле, чем хрупкая структура наследования. Если два типа меняются по разным причинам, их, скорее всего, не следует связывать.
Если два типа меняются по разным причинам, их, скорее всего, не следует связывать.
Полезный тест на основе запаха: описал бы менеджер продукта их как одну и ту же концепцию? Строка профиля пользователя в хранилище и форма регистрации на странице маркетинга редко имеют одинаковый жизненный цикл, правила валидации или владельца. Когда между ними возникают различия, производные типы усугубляют эти различия, приводя к ошибкам компиляции, совершенно не связанным с первоначальной правкой. Явные интерфейсы делают эти различия видимыми и локальными. Помощники маппинга — небольшие функции для преобразования данных от сущности к параметрам формы — обеспечивают честное преобразование во время выполнения, не связывая идентичности типов навсегда.
3. Дискриминированные союзы лучше, чем опциональные свойства
Состояние асинхронного интерфейса часто выглядит так:
// The "Optional Flag" Anti-Pattern
interface RequestState<T> {
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
data?: T;
error?: Error;
}
Потребители тогда придумывают незаконные комбинации — isSuccess без поля data или isLoading при сохранении ошибки:
if (state.isSuccess && state.data) {
return <div>{state.data.name}</div>;
}
Необязательные флаги не кодируют автомат состояний; они кодируют надежду.
Мощность дискриминированных союзов
Сделайте статус явным:
export type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
Отрисовка становится полной и безопасной:
function RenderProfile({
state
}: {
state: AsyncState<UserProfile>;
}) {
switch (state.status) {
case 'idle':
return <div>Ready to load profile.</div>;
case 'loading':
return <LoadingSpinner />; case 'error':
return <ErrorMessage error={state.error} />; case 'success':
// TypeScript guarantees state.data exists here!
return <h1>Welcome, {state.data.fullName}</h1>;
}
}
Невозможные состояния исчезают из типовой структуры, поэтому многие защитные проверки исчезают из интерфейса.
Невозможные состояния исчезают из типовой структуры, поэтому многие защитные проверки исчезают из интерфейса.
Модели с необязательными флагами также создают путаницу в аналитике и логгинге. Считается ли запрос успешным, если isSuccess равен true, но data не определён? Союзы с различением типов заставляют отвечать на этот вопрос при формировании состояния, а не когда младший инженер делает предположения в JSX. Редьюсеры и асинхронные обёртки также становятся более понятными: каждая транзиция возвращает полную версию данных, вместо того чтобы менять логические значения, которые могут противоречить друг другу.
4. Ошибка сравнения any и unknown
Использование any для отключения проверок компилятора устраняет преимущества TypeScript на границах типов. Лучше использовать unknown и применять защитные условия при обработке данных из API или localStorage.
Замените any на unknown + защитные условия типов
// Safe parsing of unknown API or localStorage data
function parseStoredPreferences(
raw: unknown
): UserPreferences {
if (
typeof raw === 'object' &&
raw !== null &&
'theme' in raw &&
(raw.theme === 'light' || raw.theme === 'dark')
) {
return {
theme: raw.theme,
fontSize:
typeof (raw as any).fontSize === 'number'
? (raw as any).fontSize
: 14,
};
}
// Safe fallback default
return {
theme: 'dark',
fontSize: 14
};
}
Внешние данные считаются ненадежными до момента их проверки; только после этого внутренний код приобретает определенную структуру.
Внешние данные считаются ненадежными до момента их проверки; только после этого внутренний код приобретает определенную структуру.
any особенно вреден на границах модулей, поскольку он влияет на последующие вычисления: один элемент типа any из JSON.parse может сделать бесполезными все проверки в рамках всей функции. unknown не позволяет этому влиянию проникнуть внутрь. Используйте библиотеки проверки схемы, когда объем данных велик, или ручные проверки, когда структура данных небольшая и стабильная. В любом случае ядро приложения должно обрабатывать только проверенные типы.
5. Измерение времени проверки типов в CI
Если редактор работает медленно, сначала измерьте его производительность, прежде чем винить сам язык:
npx tsc --noEmit --extendedDiagnostics
Отслеживайте количество инстанций, файлы и время выполнения. Часто доминируют дорогостоящие рекурсивные условия. Если система типов требует значительных усилий для компиляции, она должна обосновать эту стоимость.
Если система типов требует значительных усилий для компиляции, она должна обосновать эту стоимость.
Расширенная диагностика часто показывает, что небольшое количество файлов создаёт большинство инстанций — это зачастую рекурсивные утилиты, импортируемые ради удобства. Удаление или упрощение этих файлов может сэкономить несколько минут на каждой задаче CI. Отслеживайте их количество со временем так же, как отслеживаете размер пакета. Архитектура типов, которая не может обосновать свою стоимость компиляции, в конечном итоге будет виноватой в медленной работе TypeScript, и команда начнёт недоинвестировать в язык, который всё ещё защищает их во время выполнения программы.
6. Проблема излишней умности на уровне типов
Умность — ещё одна ловушка: типы, которые формируют всю структуру API на основе других типов, затем заполняются всё большим количеством условий, преобразованных типов и рекурсии, пока никто не захочет ими пользоваться. Наличие определённых возможностей не является основанием для использования самых сложных функций.
Сравните прямой индексированный доступ:
type UserName = UserProfile['fullName'];
с рекурсивным инструментом, который ищет каждое свойство с строковым значением в произвольном объекте. Если продукту нужен лишь UserProfile['fullName'], такая сложная структура создаёт риски. Хорошая инженерия выбирает самый простой и понятный инструмент. Тип, созданный шесть месяцев назад, должен быть самоочевидным; фраза «не трогайте это» означает, что абстракция уже провалилась.
7. Прагматичный манифест TypeScript
1. Сначала пишите типы для людей, а уже потом для компилятора
Если коллеги не могут понять определение за минуту, упростите его. Элегантность, понятная только автору, является долгом.
2. Предпочитайте дублирование вместо преждевременной связи
Не искажайте интерфейс компонента только для повторного использования трех полей из не связанной с ним сущности. Разделяйте ответственности, разделяйте типы.
3. Используйте дискриминированные союзы для представления состояний
Кодируйте настоящие машины состояний с дискриминантом статуса, чтобы компилятор мог убирать невозможные ветки.
4. Никогда не позволяйте генерикам иметь более двух параметров
Три и более генерика обычно означают, что абстракция слишком общая. Разделите её. Это гедулика, а не закон — когда отношения трудно объяснить, пересмотрите дизайн.
5. Рассматривайте внешние данные как неизвестные
Ответы API, данные хранилища, данные от сторонних поставщиков и ввод пользователя должны проверяться на границе перед тем, как стать надёжными объектами домена.
6. Измеряйте сначала, прежде чем винить TypeScript
Медленная работа CI или замедление работы сервисов обработки языка часто связано с архитектурой типов, а не с брендом языка. Определите профиль и затем упростите часто используемые типы.
TypeScript должен делать код простым
TypeScript проявляет себя в лучшем свете, когда остается незамеченным: автодополнение, безопасные рефакторинги, меньше неожиданностей во время выполнения. Он не должен казаться загадкой при каждой изменении компонента. Самые простые интерфейсы — обычные бизнес-объекты — часто оказываются наиболее ценными. Делайте типы простыми, практичными и связанными с реальными концепциями продукта, чтобы команда могла выпускать код, а не разбираться в типовой алгебре.
Делайте типы простыми, практичными и связанными с реальными концепциями продукта, чтобы команда могла выпускать код, а не разбираться в типовой алгебре.
Ретроспективы становятся более эффективными, когда разговор смещается от языковых особенностей на архитектурные решения: сколько генериков использовать, насколько сильно опираться на производные типы, насколько честной является машина состояний, как внешние данные попадают в приложение. TypeScript вознаграждает такую честность быстрой обратной связью по важным изменениям. Он наказывает излишнюю хитрость неясными ошибками. Сознательно выбирайте более простой подход, документируйте лишь те сложные типы, которые действительно необходимы, и исключайте сложные паттерны из общих библиотек, с которыми должны работать новые сотрудники с первого дня.
Когда кажется, что изменения блокируются типами, спросите себя, отражает ли модель реальный продукт. Часто решением является не более сложные условия — а более понятный интерфейс, разделение модулей или объединение типов, описывающее состояния, которые вы уже обсуждаете во время ежедневных собраний. Именно так TypeScript перестает быть лишним бременем и снова становится инструментом для эффективной разработки.