Десять звичок TypeScript, які забезпечують читабельність та безпеку великих кодових баз.
Дізнайтеся десять практичних звичок використання TypeScript — від значущих генериків та їх обмежень до повних перевірок, параметрів readonly та суворих налаштувань tsconfig — які допомагають підтримувати масштабні кодові бази у придатному стані.
Більшість проблем з TypeScript у постійно розростаючомуся кодбазі не пов’язані з незнанням того, що таке універсальний чи умовний тип. Вони виникають через повсякденні рішення: надмірно складні абстракції, типи, які приймають занадто багато даних, ключове слово as, розкидане скрізь, контракти, визначені двічі, незрозумілі сигнатури універсальних типів, функції, аргументи яких не мають значення у місці виклику, типи, розташовані у випадкових файлах, та компілятор, налаштований занадто слабко для виявлення того, на чому залежить команда. У невеликому проекті такі звички майже не помічаються; але при десятках розробників та кількох роках вони накопичуються та створюють проблеми. У цьому посібнику розглядаються десять конкретних практик, які роблять код на TypeScript зрозумілим та легким для змін, а також чек-ліст, який можна використовувати під час перегляду коду.
Якщо ви хочете спочатку дізнатися про аспекти моделювання, включаючи способи зробити недійсні стани непредставленими, почніть з моделювання доменів у TypeScript за межами базових анотацій. Тут увага зосереджена на звичках підтримки, які є основою хороших моделей.
1. Використовуйте генерики як засіб для вираження взаємозв’язків
Генерики зазвичай представляють собою механізм повторного використання, і це справді так. Однак їхнє більш важливе завдання — це з’єднувати типи: повідомляти компілятору, що результат виконання функції пов’язаний із її вхідними даними. Ось найпростіший корисний приклад — функція, яка повертає перший елемент масиву.
function getFirst<T>(items: T[]): T | undefined {
return items[0]
}
Параметр типу T береться з аргумента. Передайте масив користувачів:
const users: User[] = [...]
і компілятор визначить результат відповідно:
const user = getFirst(users)
// User | undefined
Та сама функція працює з різними типами елементів без жодних додаткових анотацій:
const products: Product[] = [...]
що дає результат обчислень із правильним типом:
const product = getFirst(products)
// Product | undefined
Тепер подивіться, що відбувається, якщо ви приберете генеричний тип та заміните його на unknown. Функція все одно працює, але зв’язок між вхідними та вихідними даними зникає, і кожен користувач мусить перетворювати або обмежувати тип результату.
function getFirst(items: unknown[]): unknown {
return items[0]
}
Гарним тестом перед введенням параметра типу є названня зв’язку, який він підтримує. Якщо ви не можете сказати, який тип вхідних даних визначає який тип вихідних, генеричний тип, ймовірно, не виконує свою функцію належним чином.
Одна деталь, яка варта уваги: при увімкненому режимі noUncheckedIndexedAccess (описаному у розділі 10) компілятор сам присвоює тип items[0] як T | undefined, що збігається з явним типом повернення тут.
2. Утримуйтеся від перетворення всього на генеричні елементи
Оскільки генеричні елементи є потужними, їх легко надмірно використовувати. Існує спокуса писати сигнатуру з кількома обмеженими параметрами типу, які залежать один від одного, як показано нижче. Це здається складним та професійним, коли ви це пишете.
function processData<
T extends Record<string, unknown>,
K extends keyof T,
R extends ...
>(...) {
// ...
}
Розробник, який відкриває цей файл через шість місяців, зазвичай має іншу думку. Кожен додатковий параметр типу — це щось, що читач повинен запам’ятати. Якщо функція насправді працює лише з користувачами, проста сигнатура передає набагато більше інформації:
function processUser(user: User) {
// ...
}
Експертизу в TypeScript не оцінюють за кількістю елементів типової системи, які можна включити в одну декларацію. Використовуйте генеричні елементи тоді, коли вони дійсно відображають реальну залежність між типами, а не просто тому, що мова це дозволяє.
3. Обмежуйте значення замість їх перетворення
Твердження про тип — це найшвидший спосіб придушити скаргу:
const value = something as string
Проблема полягає у тому, що as нічого не перевіряє. Він просить компілятор відмовитися від своєї невпевненості та повірити вам, а якщо ви помиляєтесь, помилка з’являється під час виконання. Безпечнішим підходом є доведення типу за допомогою перевірки під час виконання, яку розуміє компілятор:
if (typeof something === 'string') {
console.log(something.toUpperCase())
}
Усередині блоку if something є string, оскільки typeof є конструкцією для звуження типу. Для об’єктів потрібно написати власний типовий захист. Тип повернення value is User повідомляє компілятору, що результат true означає, що аргумент можна розглядати як User.
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
)
}
Тоді викликаючі отримують можливість звуження типу безкоштовно:
if (isUser(value)) {
console.log(value.name)
}
Контраст простий: асерція просить компілятора довіритися вам, тоді як способи звуження надають докази. Пам’ятайте, що захисник типу є настільки ж чесним, наскільки його тіло. У прикладі перевіряється наявність id та name, але не те, які типи вони містять, тому для даних з мережі чи пам’яті можливо знадобляться більш суворі перевірки чи валідатор схеми. Компілятор повністю довіряє вердикту захисника.
4. Використовуйте флаг never для неповних розгалужень
Припустимо, що статус моделюється як об’єднання рядкових літералів:
type Status =
| 'pending'
| 'approved'
| 'rejected'
switch, який відповідає кожному статусу певній мітці, здається повним:
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
}
}
Сьогодні він є повним. Проблеми починаються, коли об’єднання розширюється, наприклад, коли хтось додає статус скасування:
type Status =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled'
Status може використовуватися у десятках місць, і ви хочете, щоб компілятор вказував на кожне з них, де цей тип більше не охоплює всі випадки. Стандартним підходом є допоміжна функція для перевірки повноти охоплення, яка приймає значення never. У ветці default TypeScript вже виключив усі оброблювані варіанти, тому залишений тип має бути never. Якщо якийсь новий варіант прорветься, його неможливо присвоїти значенню never, і компіляція зазнає невдачі.
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`)
}
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
default:
return assertNever(status)
}
}
Після додавання значення 'cancelled' виклик assertNever(status) стає причиною помилки компіляції, доки ви не обробите цей новий випадок. Визначення уніону стає єдиним джерелом істини, а компілятор формує список місць, які потрібно оновити. Як додатковий плюс, оператор throw захищає програму під час виконання, якщо ззовні системи типів надходить неочікуване значення.
5. Використовуйте readonly для визначення способу використання даних
Типи описують, які значення дозволені, але вони також можуть вказувати, як ці значення слід обробляти. Позначення властивості як readonly свідчить про те, що вона зафіксована після створення об’єкта:
type User = {
readonly id: string
name: string
}
Таке присвоєння, як показано нижче, буде відхилене компілятором:
user.id = '123'
Масиви можна захистити таким самим чином. Параметр типу readonly User[] дозволяє функції ітеруватися та читати дані, але не додаєти нові елементи, змінювати їх порядок чи сортувати:
function processUsers(users: readonly User[]) {
// ...
}
Ця сигнатура повідомляє кожного викликача, що функція не буде змінювати їхню колекцію. readonly особливо корисний для об’єктів конфігурації, спільних даних, констант, параметрів функцій та незмінного стану. Основна перевага полягає не стільки у блокуванні певних змін, скільки у документуванні наміру для всіх, хто читає цей тип. Зауважте, що readonly є поверхневим та діє лише на етапі компіляції: вкладені об’єкти залишаються змінними, якщо вони також не позначені як readonly, і ніщо не заморожується під час виконання.
6. Не ховайте справжні структури за Record<string, unknown>
Подібні сигнатури є поширеними:
function process(data: Record<string, unknown>) {
// ...
}
Іноді це і є правильний тип. Якщо функція справді приймає довільні дані у форматі ключ-значення, наприклад універсальний логгер чи інструмент серіалізації, то використання широкого типу є чесним підходом. Проблема виникає, коли ви вже знаєте, що саме є об’єктом. Візьмемо ту саму сигнатуру:
function process(data: Record<string, unknown>) {
// ...
}
і описемо дані, які ми насправді очікуємо:
type User = {
id: string
name: string
}
function process(user: User) {
// ...
}
Зміна виглядає формальною, але її переваги значні: автодоповнення, вбудована документація, безпечне рефакторинг, гарантії на етапі компіляції та чітке формулювання наміру. Широкі типи підходять для справді динамічних ситуацій, наприклад під час обробки невідомого JSON, і їх слід якнайшвидше перетворювати на конкретні типи після цього, а не використовувати як стандарт скрізь.
7. Проектування API функцій, які пояснюють себе
Позиційні аргументи швидко стають незрозумілими, особливо булеві значення. Читаючи такий виклик, ви не зможете зрозуміти, що контролюють true та false, не відкривши визначення:
createUser(
'Akshat',
'akshat@example.com',
true,
false,
)
Об’єкт опцій розміщує значення безпосередньо у місці виклику:
createUser({
name: 'Akshat',
email: 'akshat@example.com',
sendWelcomeEmail: true,
isAdmin: false,
})
Потім функція оголошує іменований тип для своїх опцій:
type CreateUserOptions = {
name: string
email: string
sendWelcomeEmail: boolean
isAdmin: boolean
}
function createUser(options: CreateUserOptions) {
// ...
}
Користь від цього зростає з кількістю параметрів. Два аргументи зазвичай підходять у позиційному форматі; сім майже завжди призводять до помилок, особливо коли кілька з них мають однаковий тип та можуть бути поміняні без наслідків. Об’єкт опцій також полегшує додавання необов’язкових полів пізніше, не порушуючи роботу існуючих викликів.
8. Тримайте типи поруч із областями, які вони описують
Багато проєктів починаються з одного спільного файлу types.ts. Спочатку це зручно, але потім кожен розробник починає до нього додавати новий код, і через рік у ньому з’являється сотні не пов’язаних між собою визначень. Знаходження потрібного типу перетворюється на складний пошук, а файл стає джерелом конфліктів під час об’єднання коду.
Кращим варіантом є розміщувати типи разом із кодом того домену, якому вони належать:
users/
user.types.ts
user.service.ts
user.repository.ts
payments/
payment.types.ts
payment.service.ts
payment.repository.ts
facilities/
facility.types.ts
facility.service.ts
facility.repository.ts
Точна структура папок має менше значення, ніж правило, яке за нею стоїть: тип має знаходитися разом із доменом, який він описує. Якщо ви знаєте, де знаходиться бізнес-логіка оплат, ви також повинні здатні здогадатися, де знаходяться типи оплат. Справді універсальні типи, такі як спільні шаблони API, все одно можуть знаходитися у невеликому спільному модулі.
9. Зробіть систему типів простішою за бізнес-логіку
TypeScript пропонує маповані типи, умовні типи, шаблонні літеральні типи, рекурсивні типи, infer та дистрибутивні умови. За допомогою цих інструментів можна створювати майже все на рівні типів, і саме тому важлива стриманість. Розгляньмо такий помічник, який фільтрує об’єкт, залишаючи лише ключі, що закінчуються на Id:
type Magic<T> =
T extends infer U
? U extends Record<string, unknown>
? {
[K in keyof U as K extends `${string}Id`
? K
: never]: U[K]
}
: never
: never
Програмування на рівні типів має закономірне застосування, особливо у бібліотеках. Але існує момент, коли тип додає більше складності, ніж усуває. Якщо колегі потрібно розшифрувати складний тип, перш ніж він зможе дотримуватися бізнес-правила, яке він підтримує, запитайте, чи не краще використати простішу версію. Іноді відповідь — ні, і така складність є виправданою; часто — ні. Кмітливість не є ознакою якості. Прості та зрозумілі типи зазвичай кращі за вражаючі, а коли справді потрібен складний тип, короткий коментар та кілька тестів на типи значно полегшують його підтримку.
10. Навмисно налаштовуйте tsconfig
Один із найпростіших способів послабити TypeScript — це конфігурація, яка ігнорує саме ті проблеми, які ви очікуєте, що він виявить. Принаймні потрібно знати, що роблять ці опції:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict увімкнює низку перевірок, зокрема strictNullChecks та noImplicitAny. Ще дві перевірки є окремими опціями, які strict не активує: noUncheckedIndexedAccess додає значення undefined до результатів індексованого доступу до елементів масивів та об’єктів, а exactOptionalPropertyTypes розрізняє властивості, яких бракує, та ті, що显式 встановлені як undefined. Обидві ці опції можуть виявити багато помилок у існуючому проекті.
Правильна комбінація залежить від кодової бази. У проектах зі старим кодом можливо не вдасться одночасно увімкнути всі опції, тому поступове їх активування є цілком доцільним. Важливо, щоб команда знала, які перевірки компілятор проводить, а яких — ні, починаючи саме з цієї:
"strict": true
Режим суворості створений не для того, щоб ускладнити роботу з TypeScript. Він змушує компілятор чесно ставитися до невизначеностей, що є основною причиною використання TypeScript – виявлення проблем раніше, ніж це зроблять користувачі.
Чому ці звички важливі разом
Жодна з цих практик не має цінності як трюк. Їхня цінність полягає у тому, що вони полегшують розуміння кодової бази. Уявіть нового колегу, який стикається з таким типом:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Не читаючи жодної реалізації, він дізнається про бізнес-правило: успішна оплата має ідентифікатор транзакції, а невдала – помилку. Тип вказує, як поводиться ця частина системи, а не просто те, що певна властивість є рядком. Саме до цього стандарту слід прагнути.
Чек-лист для перевірки коду
Перед збереженням коду на TypeScript розгляньте ці запитання:
- Чи може цей
anyбутиunknownабо належати до певного конкретного типу? - Чи повторює ця анотація щось, що компілятор вже здогадався?
- Чи представляють ці типи лише допустимі стани області?
- Чи є ця властивість необов’язковою через справжню необхідність, чи заради зручності?
- Чи дозволить союз точніше описати цей стан?
- Чи використовується тут
asтому, що значення дійсно безпечне, чи просто для усунення помилки? - Чи відображає цей генеричний тип справжню взаємозв’язок між типами?
- Чи легше зрозуміти нову абстракцію, ніж код, який вона замінює?
- Чи може читач зрозуміти значення кожного аргументу у місці виклику?
- Чи допоможе
readonlyпрояснити питання власності чи незмінності? - Чи помітить компілятор зміни в цій області?
- Чи може колега зрозуміти цей тип без його декодування?
Останнє запитання, як правило, має найбільше значення.
Підсумок: типи як інструмент дизайну
З досвідом синтаксис стає найменш цікавою частиною TypeScript. Важливим є те, що ви обираєте виразити. Ви можете описати об’єкт, який випадково містить кілька рядків, або описати операцію, яка завжди знаходиться в одному з чотирьох станів, кожен з яких гарантує певний набір властивостей. Другий варіант є набагато кориснішим.
Тож якість TypeScript оцінюється не кількістю складних функцій, які може назвати розробник, а тим, наскільки ефективно система типів допомагає команді розуміти, змінювати та підтримувати програмне забезпечення. Коли компілятор дотримується правил, від яких вже залежить ваше застосування, типи перестають бути запобіжним механізмом та стають частиною архітектури.
- Використовуйте генерики для відображення зв’язків, а звичайні сигнатури — коли немає потреби фіксувати зв’язки.
readonly, точні форми та об’єкти опцій.tsconfig, та свідомо посилюйте його налаштування.