Главная / Статьи

Десять шаблонов 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 заказа. Использование специальных типов позволяет добавить фиктивное свойство, которого на самом деле нет во время выполнения кода, тем самым делая два типа несовместимыми. Функции-конструкторы становятся единственным способом создания таких значений и подходящим местом для проверки их корректности.

// 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.