Головна / Статті

Десять шаблонів 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) визначає, що доводить функція-булева величина; функція твердження (asserts val is NonNullable) кидає помилку у разі невдачі та звужує всі елементи після виклику.

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.