Галоўная / Артыкулы / Дзесять прычынакоў TypeScript, якія робяць большыя кодавесы чытабельнымі і безпечнымі

Дзесять прычынакоў TypeScript, якія робяць большыя кодавесы чытабельнымі і безпечнымі

Выучыце дзесять практычных прыемаў работы з TypeScript: ад значамых гэнерыкаў і ўзсікання апшчытнасі да выключных перакальбаванняў, атрыбутаў readonly і строгага налашоўвання tsconfig, якія дапамагаюць падтрымваць растучыя кодавыя базы.

2557 слоў

Большая частка проблем з са 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)
}

Разлік просты: асэрцыя прасіць кампайлер пераверыцца ў вас, тады як функцыя narrowing дае доказы. Памятайце, што тэпавая захоўніца ёсць настолькі ж чыстая, насколькі і ўсё, што яна знаходзіцься ў сваем тэле. У прыкладзе пераканваліваецца, чы ўсё-такі існуюць 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 є паверхневым і дзейсніцься толькі пад час кампайлявання: вярнутыя об’екты застаюцца зменнымі, якщо толькі іх таксама не пазначыць, а нічога не застойваецца пад час выканання.

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
    }

Не чытаючы жадной рэалізацыі, ён выучыць правіло бізнесу: успешны платеж мае ID транзакцыі, а невялікі — памятку пра адказ. Тып выражае, як дзейнаўцюе гэта частка системы, а не проста тое, што якаясь властасць є строкай. Гэта стандарт, да якога трэба прагнуць.

Спіс пераканальных пунктаў для адзорвання коду

Перш чым зберагаць код на TypeScript, расспытайце ся на гэтыя пытанні:

  • Чы можа гэты any быть unknown або паводлівым типам?
  • Чы гэта анотацыя павтарае тое, што кампайлер вже выважвае?
  • Чы типы практычна выражаюць толькі дзеясны станы домэна?
  • Чы гэта якосць ўмоžліваная таму, што яна справды такая, чы проста з дазволу?
  • Чы аб’юнкція могла б болей тачна описаць гэты стан?
  • Чы гэты as тут таму, што значэнне паводлівае без апаратных рызыкаў, чы проста каб згасіць адзін з бядаў?
  • Чы гэты генерычны элемент выражае рэальную зв’язку між типамі?
  • Чы новая абстракцыя лёгкая для разумення чым код, які ёна заменяе?
  • Чы чытач можа зразумець, што значыць кожны аргумент у месцы вызову?
  • Чы readonly могла б ясней паказаць прыналежнасць чы незменнасць?
  • Чы кампайлер зазначыць, калі гэты домэн змяніцца?
  • Чы колега па командзе можа зразумець гэты тип без яго декодавання?

Паўнаватая запытка часта ёсць найважлівейшая.

Узагадкі: типы як інструмент дизайна

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

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

  • Для выражэння адносаў вярцайте генерыкі, а калі няма чаго зафіксаваць як аднос, вярцайте звычныя падписы.
  • Вольба лепшая за доказательства (скарычуванне, захаванні, выключныя пераконтроўкі) над тверджэннямі.
  • Нехай типы задокументаваюць намеры за дапамою readonly, точных форм і об’ектаў з параметрамі.
  • Тыпы трэба заставляць быць ближэй да свайго домену і простымія, чым логіка, якой яны служаць.
  • Трэба точна ведаць, калі пераконтроўваецца tsconfig, і свядома яго згружваць.