Шысь тэхнік TypeScript, якія ператвараюць типы на рэальную захопчыню ад бягоў
Дазвольце даклэ научыцца, як аб’еднанні з атрыбутамі, праблемы, якія ніколі не пераканалізуюцца, невядомыя элементы, выведжаныя типы і ідэнтыфікаторы з маркірамі дапамагаюць TypeScript выявляць справжніі бягі ў часе кампайлявання, а не пад час роботы програмы.
Крыніцы: TypeScript Can Already Do This. Almost Nobody Uses It.
Праект можа згадваць TypeScript у package.json, адзначаць інтэрфейсы з дзесяткам неабяжлівых поль, але ўсё рава збівацца з крэшам «cannot read properties of undefined». Наявнасць компілятора — гэта не тое ж сама, што яго можна выкарыстоўваць у практыцы, а дадзенне : string кожнай змэннай сама па сабе малая дапамага. Шэсць тэхнік, якія паказаны нижчэй, — гэта тое, што викорыстоўваюць апытныя команды, каб праблэмы з перакананнем на типы мелі реальны значэнне; кожная з іх супакоўвана прыкладам коду та абґрунтаванням.
Праверкайце об’екты за дапамою satisfies заместо анотацый
Звычны спосаб з’ясаваць, чы гэты об’ект налаштавання мае правильную структуру, — гэта адзначыць яго анотацыямі:
const config: Record<string, number> = {
timeout: 3000,
retries: 3,
};
config.timeout.toFixed(2); // fine
Этакі код компілюецца, але анатазыя заменяе тое, што TypeScript бы вывела сама. Тып зменнай тепер прымечна Record<string, number>, таму компілятор больш не ведае, якія ключы на самай працоўнае ёсць у об’екте. config.timeout — гэта проста „якісь цыфра пад якім-небудзь строчным ключам“, а такія памылкі напісу, як config.timout, прыймаюцца без жадных заўважэнняў, а калі ўвімкнута опцыя noUncheckedIndexedAccess, кожны чытаны атрыбут стае number | undefined.
Аператар satisfies, доступны з версіі TypeScript 4.9, пераканальвае об’ект па тыпу без замены выведзенага тыпу:
const config = {
timeout: 3000,
retries: 3,
} satisfies Record<string, number>;
config.timeout; // TypeScript still knows this is literally 3000
Вы аплікуеце правільную верыфікацыю (значэнне у вигляде строкі будзе адхоўванае) і падтрымліваеце точную структуру: TypeScript знае, што об’ект мае роўна timeout і retries, а неправільна напісаныя ключы стаюць памилкамі. Адна паўтарка распашчання пасёлку, уключаючы камент у фрагменте: сама мова satisfies не падтрымлівае, каб timeout заставалася літэральным типам 3000. Атрыбуты зміннага об’екта все равно ператвараюцца ў number. Якщо вам таксама патрэбны літэральныя значэння, з’едыніце два аператары як as const satisfies Record<string, number>, чыяму об’ект стане толькі для чытання і будуць адхоўваныя ўсе літэралы. Багато разработчыкаў яшчэ і далей вжываюце гэтую анотацыю проста таму, што ўсе гады гэта быў едынственны варыянт.
Варіанты модэля як аб’еднанні з пазначкамі, а не неабавязковыя поля
Калі значэнне можа прымаць калькі форм, пасаблівым способам ёст тое, калі кожна польніца становіць необавязковай:
interface ApiResponse {
data?: User[];
error?: string;
loading?: boolean;
}
Такі тип тепер дазваляе кожную комбінацыю, укладаючы ў сябе data і error адночасна, або ж ніякога з іх разам з loading у стане false. Правіло пра тое, што заўсёды можа дзеяць толькі адны стан, знаходзіцца ў галавах людзей, і кампайлер не можа яго прымусіць.
Дискримінаваная ўнія (тэгаваная унія) кодуе гэта правіло. Кожны варіант несе літеральны тэг status і толькі тыя польніцы, якія належаць яму:
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />; // data exists, guaranteed
case 'error':
return <ErrorBanner message={response.error} />;
}
}
У кожнам case TypeScript сузае значэнне response да пасоўнаю варіянту. response.data можа быць доступны толькі ў ветке success, каліж у іншых варіянтаў вообща няма атрыбута data. Цэлы клас проблем типу „Чы гэта ўзначана тут?“ з’являецца яшчэ перад запускам коду, і той самы принцып дзейна для дзеянняў редуктара, станоў формы і рэзультатаў API.
Забяжыце, каб нехапячныя case-ы спрычынілі паўстанне пад час кампіляцыі
У з’еднаннях з атрыбутамі-тага застаецца ўсё ж адна проблема. Падазроўваю, што чырэз калькі месцацоў хтось дадае статус 'forbidden'. Нічога не змушвае апцэнаваць кожны switch над ApiResponse, таму новы case праходзіць непазначана і стае багам у працуючай версіі.
Перакантрольванне на выключнасць падпрыемаў запоўняе гэты прызнак. Галузь default прызначае скасаваную значэнне зменнай з типам never:
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />;
case 'error':
return <ErrorBanner message={response.error} />;
default:
const exhaustiveCheck: never = response;
throw new Error(`Unhandled status: ${exhaustiveCheck}`);
}
}
Калі адбываецца обработка кожнага варіанта, для response у галузі default нічога не застаецца, таму яго тип — never, і прызначэнне компілюецца. Якщо дадаць 'forbidden' без адпаведнага case, то response там стане { status: 'forbidden' }, якое не можа быць прызначанае зменнай типу never, тады кампіляванне збяжае, і будзе паказана адмовілася галузь. Команда throw таксама захоўвае вас пад час выканання, якщо прыйдуць нечаканыя даны. Абгортаванне тэлу галузі default ў клаўезы дапамагае інструментам для перакантрольвання, якія фіксуюць декларацыі ў клазулах case.
Існаванне зменной unknown на краях вашай аплікацыі
any выключае перакантрольванне типу. unknown заставляе яго працаваць і вымагае падтвердзіць, што ўсё-такі являе сабою значэнне, прычым яго. Гэта самэ ці хоча б падобнае паведамленне, якое трэба, калі прыходзяць ненадзеяныя даны: адпаведзі API, рэзультаты JSON.parse, пакеты дадзеных для webhook.
У гэтым прыкладзе функцыя fetch спецыяльна вяртае Promise<unknown>. Адразу чытанне .name вызывае памылку компілявання, і гэта стае можлівым толькі внутры типовага захавання:
async function fetchUser(id: string): Promise<unknown> {
const res = await fetch(`/api/user/${id}`);
return res.json();
}
const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
raw.name; // fine, narrowed to User inside the guard
}
isUser — это типавыяўленнік, створаны самам корыстнікам, з падписам function isUser(x: unknown): x is User; яго тэла павінны рэальна пераканацца ў формате дадзеных у час выконання. Калі викорыстоўваецца unknown, кампайлер адмовляецца ў доступе да атрыбутаў пакуль дадзеныя не будуць перакананы. З any верыфікацыя є необав’язковай, і часта адбываецца прыпінення пераканань, пакуль неякразны адпаведзень не дасягне компонента, які прыпускаў, што атрыбут завжды існуе. Для большых об’ёмаў дадзеных бібліятэкі шымату, такія як Zod, можа генераваць як перакананні ў час выконання, так і правільны тип. Больш прыкладаў такога падходу практыкуецца ў шасці безпечных замены any на іншыя типы.
Адгэтуеце типы з коду, а не падтрымайце копіі
Якщо функцыя вже ствараець значэнне, вам не патрэбна адзінокая ручная інтарфейс-структура для яе рэзультата. TypeScript можа яго выявіць:
function createOrderPayload(order: Order) {
return {
id: order.id,
total: order.total,
lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
};
}
type OrderPayload = ReturnType<typeof createOrderPayload>;
ReturnType<typeof createOrderPayload> чытае выведзены тип рэтарнування функцыі. Як толькі змянюецца функцыя, OrderPayload адразу паследуе ў всіх месцах, дзе яна выкарыстоўваная. На адміністратыўны лад, інтарфейс, які падтрымваецца вручную, выкрэсвяецца: пасля калькальных змян ён можа стварыць уявленне пра наяўнасць поля, якое функцыя давно перестала вяртаць, і кампайлер будзе павергацца ў цей застарэўшы інтарфейс.
Тая ж самая ідея прыменяецца да компанентаў з бібліятэкаў, якія не належаць вам. React.ComponentProps<typeof Button> вяртае самэ працэсы, якія приймае Button ад трэцьяго сторонньага разработчыка, взятые з яго сабеўных апісаў типаў, таму няма «ценавай копіі», якую трэба адчыніць, калі бібліятэка дадае новы працэс. Спаднія функцыі, такія як Parameters, Awaited і типы з індексаваным доступам (OrderPayload['lineItems'][number]), расшыруюць гэты падход; для адгляду перакінцецца на вбудованыя функцыональныя типы TypeScript.
Прімітывныя типы брэнда, каб ID не можна было заменіць
Па значэнню стандарту UserId і OrderId ўсё як правільныя строкі, таму перадача ID замовлення функцыі, якая чакае ID пользователя, компілюецца без проблем. Запыт проста нічога не вяртае, і хтось выкладае час на тое, каб з’ясавіць прычыну.
Брэндынг сустрэчаецца з прымітывамі за дапамою фантамнай атрыбутаў, тады гэтыя два элементы становяцца несуміснымі:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId
Атрыбут __brand ніколі не існуе ў часе выканання; ён існуе толькі в системе типаў, што дастаткова, каб TypeScript считала гэтыя два типы аднароднымі. Значэнні ствараюцца за дапамою невялікага канстрактара або функцыі пераканалення (у прыкладзе as OrderId выступае як такі канстрактар), і з таго моменту кожна випадковая памешчанне стае адмовай у компіляцыі без жадных затрат у часе выканання. Зберагаюце касты ў тых некалькох канстрактарах, каб атрыбут __brand дасканальна значыў „гэтае значэнне было пераканалена“.
Узагадкі
Для гэтага не трэба новай палаткі чы бібліятэкі; гэта проста TypeScript, якая ўжо у вас є, і якая вы используеце цэлесапраўедна:
satisfiesпераканалюе без адкладання выведзенага формата; дадзіцеas const, калі важны літэралы.
never ператварае забуты кейс у памятку пра бягаванне.unknown на межах вымагае прабавы перад викорыстоўваннем.Выберыце адну тэхніку і застосавіце яе ў вашай наступной просьбе про з’еднанне. Кожная з іх пераводзіць певную категорыю багоў з часу выканання на час кампілявання, і гэта ёсць практычная разліка межы інсталявання TypeScript і яго захоплення вашага коду.