Галоўная / Артыкулы

Шысь тэхнік TypeScript, якія ператвараюць типы на рэальную захопчыню ад бягоў

Дазвольце даклэ научыцца, як аб’еднанні з атрыбутамі, праблемы, якія ніколі не пераканалізуюцца, невядомыя элементы, выведжаныя типы і ідэнтыфікаторы з маркірамі дапамагаюць TypeScript выявляць справжніі бягі ў часе кампайлявання, а не пад час роботы програмы.

Выдавец: REACTAPP.TOP7 хв чытання1627 слоў

Крыніцы: 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 і яго захоплення вашага коду.