Моделювання доменів у TypeScript: за межами базових анотацій типів
Дізнайтеся про практичні звички роботи з TypeScript — від використання типів unknown та any до дискримінованих союзів та оператора satisfies — які допоможуть вам моделювати коректні стани замість простого позначення даних.
TypeScript насправді досить простий у освоєнні.
Спочатку ви вивчаєте інтерфейси.
Потім — аліаси типів.
Далі йдуть союзи, генерики, типи-функції та іноді маповані типи.
Незабаром ви зможете поглянути на звичайний об’єкт JavaScript та без вагань призначити йому тип.
Але в певний момент робота з TypeScript більше не полягає у простому призначенні типів до об’єктів.
Це перетворюється на систематичне проектування типів.
Це зовсім інша навичка, яку потрібно розвивати.
Подивіться на цей приклад:
type Payment = {
status: 'SUCCESS' | 'FAILED'
transactionId?: string
error?: string
}
На перший погляд усе здається гаразд.
Але подумайте, які стани технічно дозволяє цей тип.
Він дозволяє усі наступні:
{
status: 'SUCCESS'
}
{
status: 'SUCCESS',
error: 'Something went wrong'
}
{
status: 'FAILED',
transactionId: '123'
}
{
status: 'FAILED',
error: 'Something went wrong'
}
У цьому типі немає уявлення про те, які комбінації насправді мають сенс разом.
Це не є обмеженням самого TypeScript.
Це ознака того, що домен було погано спроєктовано.
Краща версія виглядає так:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Тепер система типів безпосередньо кодує фактичне бізнес-правило.
Успішна оплата має містити ідентифікатор транзакції.
Невдала оплата має містити повідомлення про помилку.
Комбінації, які не мають сенсу, стають складними або взагалі неможливими для створення.
Саме тут TypeScript починає справді бути корисним.
Суть не в тому, щоб розкидати анотації типів всюди, де це можливо.
Це про те, щоб ваші типи відображали правила, яких насправді дотримується ваше застосунок.
Нижче наведено кілька звичок, які допоможуть вам рухатися в цьому напрямку.
1. Перестаньте використовувати any, коли насправді маєте на увазі „Я не знаю“
Один із найшвидших способів припинити скарги TypeScript — це:
const response: any = await fetchData()
Іноді саме так і відбувається.
Ви стикаєтесь з помилкою.
Ви перебуваєте посеред реалізації.
Ви не можете відразу визначити правильний тип.
Тож використовується any.
Компілятор замовкає.
Але разом із ним зникає й уся допомога, яку надавав вам TypeScript.
Як тільки any потрапляє у ваш кодовий базис:
const response: any = await fetchData()
response.user.profile.name // not checked
response.foo.bar.baz // not checked
TypeScript не має способу виявити жодну з цих помилок.
Використовуйте unknown, коли значення справді невідоме
const response: unknown = await fetchData()
Це змушує вас фактично визначити, що таке значення, перш ніж його використовувати.
if (typeof response === 'string') {
console.log(response.toUpperCase())
}
Для всього, що складніше за примітиви, краще перевіряти структуру на межі обробки.
Тут важлива різниця:
unknownкаже: «Я ще не знаю цього».anyкаже: «Я зовсім не хочу, щоб TypeScript перевіряв це».
Це дві зовсім різні мети.
Коли ви працюєте з даними, які надходять ззовні вашої системи, unknown майже завжди є більш точною вихідною точкою.
2. Не пишіть те, що TypeScript вже знає
Написання сильно типового коду не означає ручного позначення кожної змінної.
Ця версія:
const name: string = 'Akshat'
const age: number = 30
const active: boolean = true
не є за своєю суттю кращою за цю:
const name = 'Akshat'
const age = 30
const active = true
TypeScript вже може самостійно визначити ці типи.
Позначення всього лише створює візуальний хаос без додавання справжньої інформації.
Явні анотації мають сенс тоді, коли вони передають щось значуще.
Наприклад:
function calculateTotal(
items: Product[],
discount: number
): number {
// ...
}
Тут підпис функції фактично документує частину контракту.
Це справді корисна інформація.
Корисною перевіркою є:
Чи повідомляє ця анотація TypeScript щось, чого він не міг з’ясувати самостійно?
Якщо відповідь «ні», її, ймовірно, можна просто видалити.
3. Використовуйте as const, коли значення є також типами
Розгляньмо об’єкт наступного вигляду:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
}
Іноді ви хочете, щоб окремі значення залишалися у своїх первинних типах, а не перетворювалися на string.
Саме це дає вам as const:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
Звідти:
type Status = typeof STATUS[keyof typeof STATUS]
отримуємо:
'ACTIVE' | 'INACTIVE'
Ця схема є корисною, коли вам потрібні як значення під час виконання, так і відповідний тип на момент компіляції з однієї ж визначення.
Наприклад:
export const ALTERNATE_CODE_TYPES = {
CHARGE_CODE: 'CHARGE_CODE',
NFTP_MDG_CODE: 'NFTP_MDG_CODE',
FACT_MDG_CODE: 'FACT_MDG_CODE',
CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const
export type AlternateCodeType =
typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]
Тут сам об’єкт та похідний тип мають спільне походження.
Це означає, що вам не потрібно підтримувати окреме оголошення, як-от:
type AlternateCodeType =
| 'CHARGE_CODE'
| 'NFTP_MDG_CODE'
| 'FACT_MDG_CODE'
| 'CW1_CHARGE_CODE'
окремо від нього.
Зберігання єдиного джерела інформації значно простіше у керуванні, ніж синхронізація двох визначень вручну.
4. Використовуйте типи-уніони, коли домен має фіксований набір станів
Коли значення може мати лише кілька можливих значень, ваші типи повинні це прямо вказувати.
Замість того, щоб писати:
function setStatus(status: string) {
// ...
}
краще використовувати:
type Status = 'pending' | 'approved' | 'rejected'
function setStatus(status: Status) {
// ...
}
З цим підходом наступний виклик працює без проблем:
setStatus('approved')
але цей виклик відхиляється:
setStatus('something-else')
Чим строгішим є тип, тим більше роботи може виконати за вас компілятор.
Ця перевага значно перевищує можливості автодоповнення в редакторі. Точний союз також допомагає у:
- рефакторингу
- документуванні
- виявленні помилок
- дизайні API
- знаходженні інформації
Якщо ваша бізнес-логіка справді дозволяє лише три можливі значення, не представляйте це поле як вільний рядок.
5. Не використовуйте Enum автоматично
Enum іноді є правильним інструментом, але вони не повинні бути вашим стандартним вибором для кожної групи констант.
Якщо вам потрібен лише союз на етапі компіляції, достатньо чогось на кшталт:
type Status = 'ACTIVE' | 'INACTIVE'
цього часто є достатньо.
Якщо вам також потрібно, щоб ці значення існували під час виконання, використовуйте:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
type Status = typeof STATUS[keyof typeof STATUS]
Це дає вам як тип, так і справжній об’єкт для роботи.
Ключова деталь, яку потрібно засвоїти, — це те, що типи TypeScript зникають після запуску коду. Звичайний об’єкт так не робить.
Тож потрібно поставити собі таке запитання:
Чи потрібно, щоб це значення існувало під час виконання, чи воно існує лише для обмеження дій під час компіляції?
Виберіть підхід, який відповідає цій відповіді.
6. Зробити неможливим представлення недійсних станів
Це, можливо, найцінніша ідея в усьому цьому обговоренні.
- loading
- ready
- submitting
- successful
- failed
Типовий, але недосконалий спосіб моделювання цього — це:
type FormState = {
loading: boolean
submitting: boolean
error?: string
data?: FormData
}
З такою структурою ніщо не заважає вам випадково створити щось на кшталт:
{
loading: true,
submitting: true,
data: {...},
error: 'Something went wrong'
}
Що насправді означає ця комбінація? Система типів не має про це уявлення, як і наступний розробник, який її прочитає.
Краща структура поєднує поля між собою на основі стану:
type FormState =
| { status: 'loading' }
| { status: 'ready'; data: FormData }
| { status: 'submitting'; data: FormData }
| { status: 'success'; data: FormData }
| { status: 'error'; error: string }
Тепер кожна гілка містить саме ті дані, які є для неї зрозумілими.
function render(state: FormState) {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'ready':
return state.data
case 'submitting':
return 'Submitting...'
case 'success':
return state.data
case 'error':
return state.error
}
}
Ось яку перевагу надають дискриміновані союзи.
Замість того, щоб моделювати додаток як сукупність незалежних булевих значень та необов’язкових полів, ви моделюєте його як фіксований набір легітимних станів. Це набагато надійніша основа.
7. Будьте обережні з необов’язковими властивостями
Необов’язкові поля мають своє застосування, але вони також є простим способом приховати невизначеність, не помічаючи цього.
Розгляньмо цей приклад:
type User = {
id?: string
name?: string
email?: string
}
За цим визначенням кожен фрагмент коду, який використовує об’єкт User, тепер мусить обробляти ситуацію, коли жодне з цих полів відсутнє.
Але, можливо, справжнім правилом у цій галузі є:
У кожного користувача завжди є ідентифікатор, ім’я та адреса електронної пошти.
Якщо це так, то моделюйте це саме так:
type User = {
id: string
name: string
email: string
}
Необов’язкові властивості повинні відображати поля, які справді іноді відсутні. Вони не призначені для того, щоб замінювати нечітке погодження про те, що автор типу не був впевнений у тому, що саме надішле API.
Якщо невизначеність походить від якоїсь зовнішньої системи, обробляйте її прямо на цьому рівні інтеграції. Не дозволяйте невизначеності від однієї інтеграції поширюватися на весь кодовий базис.
8. Розуміння різниці між null та undefined
На практиці ця різниця виявляється значно важливішою, ніж очікують люди.
Візьмемо такий приклад:
type User = {
middleName: string | null
}
Цей формулювання свідчить про те, що:
Поле існує, але навмисно для нього не вказано значення.
Тепер порівняймо його з:
type User = {
middleName?: string
}
що зазвичай означає:
Це поле може взагалі не існувати.
Ця різниця стає особливо важливою у контексті API. У запиті типу PATCH цей тіло:
{
middleName: null
}
може означати:
Видалити існуюче прізвище середнього імені.
тоді як це тіло:
{}
може означати:
Залишити прізвище середнього імені незмінним.
Якщо типи не можуть відображати цю різницю, у самому шарі API можуть з’явитися приховані помилки.
Пам’ятайте, що типи існують для передачі значень, а не лише для задоволення вимог компілятора.
9. Використовуйте satisfies замість сліпого підтвердження типів
Розгляньмо тип конфігурації наступного вигляду:
type Config = {
timeout: number
retries: number
}
Один із варіантів — написати:
const config = {
timeout: 5000,
retries: 3,
} as Config
Але as є формою підтвердження, і його використання фактично означає, що ми просимо компілятор без заперечень прийняти це значення.
Загалом кращим підходом є:
const config = {
timeout: 5000,
retries: 3,
} satisfies Config
За допомогою цієї версії TypeScript насправді перевіряє, чи об’єкт відповідає типу Config, зберігаючи при цьому вужчий, автоматично визначений тип самого об’єкта.
Простий спосіб запам’ятати різницю:
as
Розглядайте це значення так, ніби воно належить до цього типу.
satisfies
Переконайтеся, що це значення відповідає вимогам цього типу.
Саме тому satisfies є особливо корисним для об’єктів конфігурації, статичних мапувань та таблиць пошуку.
10. Використовуйте as як межу, а не як стандартний інструмент
Іноді справді потрібна перевірка типу. Але написання чогось на кшталт цього:
const user = response as User
насправді не перевіряє нічого під час виконання.
Припустимо, виклик API справді повертає:
{
username: 'akshat'
}
У TypeScript немає способу виявити неузгодженість, оскільки асерція вже наказала прийняти значення без перевірки. Компілятору тут нічого не доводиться — його просто просять ігнорувати це.
Це стає ризикованим у випадках, коли дані надходять ззовні кодбази, наприклад:
- відповіді API
- localStorage
- параметри URL
- змінні середовища
- дані, введені користувачем
- бібліотеки сторонніх розробників
Кожного разу, коли дані надходять до додатку з джерела, яке TypeScript не може бачити, варто перевірити ці дані, а не змінювати їх тип. Перевірка схеми під час виконання дійсно може підтвердити щось на кшталт:
"Ці дані дійсно відповідають формату, якого очікує додаток."
Це набагато сильніша гарантія, ніж просте написання:
value as User
TypeScript — це інструмент часу компіляції, причому дуже хороший. Він ніколи не створювався для перевірки того, що відбувається під час виконання програми.
Справжня мета: моделювання домену
Як тільки цей підхід стає зрозумілим, TypeScript більше не здається просто вправою з синтаксису. Замість того, щоб запитувати «Як мені ввести цей об’єкт?», ви починаєте запитувати «У яких станах насправді може перебувати цей об’єкт?». Замість того, щоб запитувати «Чи має ця властивість бути необов’язковою?», ви починаєте запитувати «Чи справді ця властивість є необов’язковою, чи вона просто приховує щось, що ще невідомо?». Замість того, щоб запитувати «Чи можна тут використовувати as?», ви починаєте запитувати «Чи можна дійсно довести, що це значення має заявлений тип?»
Саме ця зміна є суттю. Написання якісного TypeScript — це не про додавання ще більше анотацій типів, а про те, щоб типи, які ви все-таки пишете, справді щось означали.
Просте правило, яке варто пам’ятати
Кожного разу, коли ви проектуєте тип, поставте собі три запитання:
1. Які стани насправді є допустимими?
Якщо певний тип дозволяє представляти недійсні стани, ймовірно, саму модель потрібно переосмислити.
2. Що вже знає компілятор?
Уникайте анотацій лише через звичку — нехай механізми висновків виконують ту роботу, яку вони вже можуть здійснити.
3. Де ці дані стають надійними?
Чим далі дані подорожують від свого початкового зовнішнього джерела, тим більшу впевненість їх типи повинні здатні виражати.
Міцний TypeScript визначається не ступенем складності його типів, а типами, які роблять правильну реалізацію очевидною, а неправильну — складною для написання.
Як тільки типи проектуються з таким підходом, TypeScript більше не здається шаром, прикріпленим до JavaScript. Він стає частиною процесу реального будування додатку.
Пов’язана література
- Поширені помилки JavaScript та TypeScript, які тихо ламають код — пояснюється суттєві проблеми JavaScript та TypeScript — від порівнянь з NaN до асинхронного таймінгу та примусової заміни типів — які спричиняють баги, незважаючи на зовнішню правильність коду.
- Заміна
anyу TypeScript: шість безпечних за типом патернів для поширених ситуацій — дізнайтеся про практичні, безпечні за типом альтернативиanyу TypeScript — включаючи типи unknown, генеріки, дискриміновані союзи та повні перевірки — для роботи з непередбачуваними даними.