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

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

Выучыце дзесяткі тэхнік TypeScript — ад разлічных союзаў і функцыі satisfies да типаў з маркірамі і функцыі infer — якія дазволяюць кампайлеру адхіляць некоректныя станы пры выкліканні вашага коду.

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

Крыніцы: Top 10 TypeScript Techniques Senior Developers Secretly Use

Многі команды вжываюць TypeScript, дадзяўшы анотаціі да існуючага JavaScript і на гэтым завершыўшы. Це дапамагае выловіць падбіркі, але заставляе большую частку системы типаў не выкорыстоўванай. Большая перадчыннае — цэе кодаванне вашых правіл у вачынку типаў, так што цэлыя класы памылак не можуць быть скомпільаваны, і не трэба ніякіх пераказоў пад час выканання. Ёсць дзесяць тэхнік, якія гэта робяць, з прыметкамі пра тое, дзе кожная з іх ўжытковая.

Явна задаюце станы модэля за дапамогою дискримінаваных аўнасоў

Пашырэнай прычыною є опис вялічыны як купкі неабавязковых поль і дазволенне тым, хто яе выкарыстоўвае, самім пераканацца ў правым камбінатары. Нічога з таго, што напісана нижэй, не запярчвае таго, каб і data, і error былі заданы.

// nothing stops you from having both data and error at once
type ApiResponse = {
  data?: User
  error?: string
  loading?: boolean
}

// consumer has no idea what to check
function handleResponse(res: ApiResponse) {
  if (res.data) { /* maybe fine */ }
  // what if data AND error are both set?
}

У разлічнай уніі перыявляюцца законныя форматы. Кожны варіант мае спольную літеральную палату (status), і TypeScript сяродзіць на яе: у галузі success гарантаванаеся наявнасць res.data, а ў галузі error — res.message.

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }

function handleResponse(res: ApiResponse) {
  switch (res.status) {
    case 'loading':
      return showSpinner()
    case 'success':
      // TypeScript KNOWS res.data exists here
      return renderUser(res.data)
    case 'error':
      // TypeScript KNOWS res.message exists here
      return showError(res.message)
  }
}

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

Праверыце об’екты за дапамою satisfies без ўзьміцца ў вялікія форматы

З’явілася ў TypeScript 4.9, функцыя satisfies выправляе тонкі проблема з анотаціямі. Код const config: Config = ... пераглядае об’ект, але тип зменнай стае самэй Config, таму config1.theme мае формат цэлага аб’еднання, а не 'dark'. За дапамою satisfies об’ект все равно пераглядаецца (памылкі ў ключах вважаюцца памылкамі), пры тым залишаецца точны выведзены тип: config2.theme застаецца 'dark', а secondary застаецца вядомым ключам.

type Config = {
  theme: 'light' | 'dark' | 'system'
  colors: Record<string, string>
}
// with a normal annotation you lose the literal type
const config1: Config = {
  theme: 'dark',
  colors: { primary: '#3d6fe8' }
}
// config1.theme is now 'light' | 'dark' | 'system', not 'dark'

// with satisfies, the shape is validated but types stay narrow
const config2 = {
  theme: 'dark',
  colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config// config2.theme is still 'dark' — the literal type is preserved
// config2.colors.primary is still 'string' — shape is validated
// if you typo a field name, TypeScript still catches it

Анотавайце, калі хочаце шырэйшы тип, напрыклад, для значэння, якое вы будете перазначаць; вжывайце satisfies, калі хочаце атестацыю плюс точны выведзення типу.

Стварэнне аб’еднання строк за дапамою шаблонных літералных типаў

У TypeScript можна ствараць типы строкі з викорыстаннем синтаксісу з апострабамі. Аб’юнкцыя ў месцах заместаў расширваецца да кожной можлівай комбінацыі, што падходзіць для імен працоўнікаў з адборам супэрвізара, спецыяльных настаўк CSS і канцаўак METHOD /path.

type EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'

type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties

type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes

type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...

function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users')     // valid
callApi('PATCH /users')   // TypeScript error: not in the type

callApi('PATCH /users') адхіляецца, таму што PATCH не ўключаны ў HttpMethod. Комбінацыі швыдка множацца, таму трэба трыважыць аб’юнкцыі вхідных дадзеных, каб не споваліць компілятор.

Раздзеляйце схожыя значэння за дапамойю типаў з маркірамі

TypeScript порৱняе типы па структурэ, таму type UserId = string ёсць проста string, і ID пользователя можа беспроблемна прайсці ў функцыю, якая чакае ID замовлення. Маркіраванне перакрывае прымітіўны тип фантомным атрыбутам, які ніколі не існуе пад час выканання, чым робіць гэтыя два типы несумеснымі. Функцыі-канстрактары стаюць ўнікальным спосабам стварэння такіх значэнняў і прыродным месцам для пераканання ў ўсуненні бягункавасцей.

// without branding — TypeScript cannot tell these apart
type UserId = string
type OrderId = string

function deleteOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // no error - catastrophic bug

// with branded types - structurally different, enforced
type Brand<T, B> = T & { readonly _brand: B }
type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// constructor functions act as the only way to create these types
const UserId  = (id: string) => id as UserId
const OrderId = (id: string) => id as OrderId
const userId  = UserId('user-42')
const orderId = OrderId('order-99')
deleteOrder(userId)  // TypeScript error: UserId is not assignable to OrderId
deleteOrder(orderId) // works fine

Хорашымі кандыдатамі ўжоўцяюцца ідэнтыфайеры (UserId, OrderId, ProductId), перакананыя значэння, такія як EmailAddress, HashedPassword або ValidatedInput, а таксама елементы, якія лёгка супутнуюцца, напрыклад мілісекунды проты секундаў або доляры проты цэнтав. У гэтым фрагменте кожны аліяс прызначаецца два разы для параболі; у рэальным кодзе залишыцца толькі версія пад маркетам.

Выдэленне типаў з іншых типаў за дапамогою infer

Унутры умовнага типу infer прызначае слот, які TypeScript запоўняе праз падбор патэрана. Самэй так ствараюцца ReturnType, Parameters і Awaited, і гэта дазволяе пісаць савастныя функцыі выдэлення.

// extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() { return { id: 1, name: 'Alice' } }
type User = ReturnType<typeof getUser>
// User = { id: number, name: string }
// extract the resolved value of a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type Data = UnwrapPromise<Promise<User>>
// Data = User
// extract the element type of an array
type ArrayElement<T> = T extends (infer E)[] ? E : never
type Names = ArrayElement<string[]>
// Names = string
// extract first argument of any function
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never
type Arg = FirstArg<typeof getUser>
// Arg = never (no args), try a fn with args:
type A2 = FirstArg<(id: string) => void>
// A2 = string

Кожную дэфініцыю трэба чытаць так: «Якшо T падходзіць да гэтага формата, што будзе ставіцца ў локацыю infer?» Якшо шаблон не падходзіць, выграўае неправы варіант, таму FirstArg функцыі без аргумента ёстся never. Давайце своім памагальным функцыям унікальныя імены, а не перакрывайце глобальную змяню ReturnType.

Змусіць непূরныя операторы switch вярнуць never

Спаруйце альянсы з памагальной функцыяй, якая прыме толькі never. Калі кожны варіант мае свой case, тип, які застаёцца ў default, будзе never, і код скомпілюецца. Якшо дадаць hexagon без обработкі, засталы тип заблокуе компіляцыю.

type Shape =
  | { kind: 'circle';    radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle';  base: number;  height: number }

// this helper makes the default case scream at compile time
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      return shape.width * shape.height
    case 'triangle':
      return 0.5 * shape.base * shape.height
    default:
      return assertNever(shape)
      // if you add 'hexagon' to Shape and forget to handle it,
      // TypeScript throws an error RIGHT HERE at compile time
  }
}

Пакалі памагальная функцыя таксама выклеквае адказ, яна захоўваеся ад нечаканых значэнняў, якія можа прыйсці зза меж тыповай системы, напрыклад, з адказаў API.

Перакашванне тыпаў за дапамой карэльваных тыпаў і перекарэлвання ключоў

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

// make all properties optional but keep their types
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// prefix all keys with 'get'
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// { getId: () => string, getName: () => string, getEmail: () => string }
// pick only function properties from a type
type FunctionKeys<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void, delete: () => void }

Патрэбны string & K, таму што ключы таксама можу быть цифрамі або сімволамі, якія Capitalize адхоўчае. Шырокі DeepPartial таксама рэкурсыўна працуе з функцыямі і масавымі значэннямі, што падходзіць для простых дадзенняў, але можа патрабаваць дадатковай прыкруткі.

Навучыце кампайляр вашым сабскрыпціям за дапамой тыповых ахоўнікаў

TypeScript скаржае типы за дапамою typeof, instanceof і порэванаў літэралай, але не ў вашых спецыяльных перакананнях. Предыкат типу (animal is Cat) адзначае, што падтверджуе функцыя з значэнням boolean; функцыя асерціі (asserts val is NonNullable<T>) выклекае адказ у разы невярнасці і скаржае всё, што знаходзится пасля вызову.

type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog

// type predicate - return type tells TypeScript what the guard proves
function isCat(animal: Animal): animal is Cat {
  return animal.kind === 'cat'
}
function speak(animal: Animal) {
  if (isCat(animal)) {
    console.log(animal.meows) // TypeScript knows animal is Cat here
  } else {
    console.log(animal.barks) // TypeScript knows animal is Dog here
  }
}
// assertion function - throws if condition not met, narrows after the call
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
  if (val === null || val === undefined) {
    throw new Error('Value is null or undefined')
  }
}
const user = getUser() // returns User | null
assertDefined(user)    // throws at runtime if null
console.log(user.name) // TypeScript knows user is User here, not User | null

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

Зберагаеце тачнасць літэральных значэнняў за дапамою as const

По замовчэнню TypeScript расшырвае літэралы: 'production' стае string. Атрыбут as const фіксуе выраз у його самым вузькім типе і робіць яго readonly. Асалёгайнае прынцыпа — гэта можлівасць стварання аб’юній на базе дадзейнаў за дапамою typeof і індексаванага доступу, так што массив або об’ект стае ўнікальным источнікам правды.

// without as const — types are widened
const config = {
  env: 'production',     // inferred as string, not 'production'
  port: 3000,             // inferred as number, not 3000
  features: ['auth', 'search'] // inferred as string[]
}

// with as const - every value is exactly as written
const config = {
  env: 'production',
  port: 3000,
  features: ['auth', 'search']
} as const
// config.env is 'production', not string
// config.port is 3000, not number
// config.features is readonly ['auth', 'search']
// the real power: derive union types from the values
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
// add a new role to the array and the type updates automatically
const ROUTES = {
  home:    '/',
  users:   '/users',
  profile: '/profile'
} as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'

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

Змяненне формы выходных і вхідных дадзейнаў за дапамою умовных типаў

Умовныя типы дазваляюць гэнерычной функцыі вяртаць разныя форматы залежна ад яе аргументаў, і самэй так Prisma задае типы для рэзультатаў include і select. Ёсць прыклад, дзе includePosts: true дае тип з элементам posts, а ў другым прыкладзе publishedAt становіцься обавязковым толькі у режыме publish.

// function returns different shapes based on a flag
type UserResponse<T extends boolean> =
  T extends true
    ? { user: User; posts: Post[] }   // with posts
    : { user: User }                   // without posts

function fetchUser<T extends boolean>(
  id: string,
  includePosts: T
): Promise<UserResponse<T>> {
  /* implementation */
}
// TypeScript knows exactly what comes back
const result1 = await fetchUser('123', true)
result1.posts   // valid - TypeScript knows posts exists
const result2 = await fetchUser('123', false)
result2.posts   // TypeScript error - posts does not exist on this return type
// another real world example: optional fields based on mode
type CreateMode = 'draft' | 'publish'
type ArticleInput<M extends CreateMode> =
  M extends 'publish'
    ? { title: string; content: string; publishedAt: Date }
    : { title: string; content?: string }
function createArticle<M extends CreateMode>(
  mode: M,
  input: ArticleInput<M>
) { /* ... */ }
// publishedAt is REQUIRED when mode is 'publish'
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() })
// publishedAt is NOT required when mode is 'draft'
createArticle('draft',   { title: 'Hey' })

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

Як усе гэта вяліся разам

Это не адзортныя трыкі. Аюніі задаюць форматы дадзеных, never не дазволяе выкарыстоўваць ўсі можлівыя варыянты, as const і шаблонныя літэралы ствараюць аюніі, а мапаваныя та умовныя типы вырахоўваюць рэшту. Тыпы стаюць шаром логіки, а не дакументацыяй. Апісанне нижэй служыць чарткай для перагляду; ён прыпускае, што існуюць такія типы, як Config, User і Animal.

// 01. Discriminated unions — model every valid state explicitly
type State = | { status: 'idle' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02. satisfies - validate shape without widening literal types
const x = { mode: 'dark' } satisfies Config
// 03. Template literal types - build string unions programmatically
type Event<T extends string> = `on${Capitalize<T>}`
// 04. Branded types - prevent mixing structurally identical primitives
type UserId = string & { readonly _brand: 'UserId' }
// 05. infer - extract types from inside other types
type Unwrap<T> = T extends Promise<infer V> ? V : T
// 06. assertNever - exhaustiveness check at compile time
function assertNever(x: never): never { throw new Error('Unhandled case') }
// 07. Mapped types - transform type shapes programmatically
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }
// 08. Type guards - teach TypeScript how to narrow your types
function isCat(a: Animal): a is Cat { return a.kind === 'cat' }
// 09. as const - lock values to literals, derive union types from data
const ROLES = ['admin', 'editor'] as const
type Role = typeof ROLES[number]
// 10. Conditional types - return different shapes based on input
type Res<T extends boolean> = T extends true ? { data: User; posts: Post[] } : { data: User }

Галоўныя выводы

  • Валідзіруйце точныя форматы дадзеных замест апшаронных полей; аюніі паўсцоўна з assertNever ператвараюць відсутныя кейсы на памылкі падчас стварэння.
  • Выкарыстоўваюце satisfies і as const, каб пераканацца ў правамільнасці значэнняў, захаваючы при этым літэральныя типы.
  • Надаюце значымыя імены прімітівам, каб ID-ы та единіцы вазны не маглі быць замененыя.
  • Вырахоўвайце типы за дапамогою infer, мапаваных та умовных типаў, але заставляйце іх быць чытабельнымі.
  • Спрытныя гвардыі і касты трэба спрацоўваць як межы довер’я.
  • Ёсць больш дакладнае выявленне правіл бізнесу — адглядзіце моделюванне домэнаў у TypeScript.