Головна / Статті / TypeScript не є повільним — це через надмірно складні типи.

TypeScript не є повільним — це через надмірно складні типи.

Універсальний суп, передчасне використання механізму DRY для різних типів, стани з опційними флагами та „розумні“ рішення на рівні типів уповільнюють роботу. Краще використовувати прості інтерфейси, дискриміновані союзи та обережно оцінювати витрати, пов’язані з tsc.

2143 слів

Як генеричні супи, гімнастика з умовними типами та передчасна абстракція уповільнюють швидкість виконання.

Під час аналізу після спринту молодий інженер зізнається, що додавання одного необов’язкового поля до існуючого набору даних API зайняло чотири години. IDE показує причину: інтерфейс, побудований з вкладених умовних операторів.

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; зробіть публічні параметри простими. Тоді компілятор вказуватиме на саме те поле, яке не збігається, замість того щоб об’єднувати чотири змінні інференції в незрозумілу ситуацію типу unknown.

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 перестає бути надлишковим обтяженням та знову стає корисним інструментом.