Моделюванне домэнаў у TypeScript: за межамі базовых анотацый типаў
Выучыце практычныя прыкметы выкарыстоўвання TypeScript — ад разліку межу «не вядома» і «будзь-што» да з’еднанняў з дысцримінацыяй і функцыі satisfies — якія памагаюць вам моделіраваць правільныя станы, а не проста пазначаць данні.
Тайпскріпт насправды дася навучыцца дастаткова проста.
Спачатку вы вучыцеся пра інтэрфейсах.
Потым — пра аліясах типаў.
Пасля чаго прыходзяць ўніяны, гэнерыкі, типы-засобы і часамы мапаваныя типы.
Чырз некалькі часоў вы зможаце проста паглядзець на звычны об’ект JavaScript і без ваганоў прызначыць яму тип.
Але ў певны момент работа з Тайпскріптам перестае быць проста прызначэнням типаў да об’ектаў.
Гэта становіцца проектаваннем типаў з яшчэ большай цярплівасцю.
Это абоўсумна іншы навык, які трэба развіваць.
Паглядзіце на гэты прыклад:
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'
}
У цьму типе няма панявы пра тое, якія комбінацыі на самай працоўнай адчуваюцца логічна.
Гэта не ўскладненне самога Тайпскріпту.
Это знак таго, што домэн быў пабудаваны слаба.
Кращая версія выглядае так:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Тепер шырака система безпосередна кодуе рэальныя правілы бізнесу.
Успешны платеж должен мець ID транзакцыі.
Неспяшны платеж должен мець прыемлівую паведамленне пра адзінак.
Комбінацыі, якія не маюць сэнсу, становяцца складнымі, або восьма немагчымымі для стварэння.
Самэўсёлі тут 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, тепер должен карацься ситуацыяю, калі жадных з гэтых поль не існуе.
Але можа справжняя правіла ў гэтым домене ёсць такая:
Кожны User завжды мае ID, імя і адресу электронной пошты.
Якщо гэта правда, то апісваць модель так:
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 немае можлівасці пазначыць такую несувяместнасць, таму што асэрцыя вже павеліла яму прыйняць значэнне такім, якое ёно є. Кампайлеру тут нічога не даводзится — яго проста прасяць не зважаць на гэта.
Гэта становіцца рызыкавым там, дзе даныя праходзяць з за меж кодбазы ў яе, напрыклад:
- Адпаведзі AI
- 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 — укладаючы неканфірмаваныя типы, гэнерыкі, дискримінаваныя ўніі і выключныя пераканвання — для обработкі неперапрацавальных дадзеных.