Что такое сужение типов (narrowing) и type guards в TypeScript?

MiddleTypeScript #typescript #narrowing #guards

Вопрос

Как работает сужение типов (type narrowing) и что такое type guards, в том числе пользовательские (value is Type)?

Короткий ответ

Narrowing — это процесс, при котором компилятор сужает широкий тип (например, union) до более узкого внутри определённой ветки кода на основе проверок вроде typeof, instanceof, in или пользовательской функции-предиката. Type guard — это любая такая проверка, а value is Type — синтаксис для написания собственной функции-предиката, влияющей на вывод типов.

Подробный ответ

TypeScript анализирует поток управления (control flow analysis) и автоматически сужает тип переменной после проверок:

typeof — для примитивов:

function format(value: string | number) {
  if (typeof value === 'string') {
    return value.toUpperCase() // value: string
  }
  return value.toFixed(2) // value: number
}

instanceof — для проверки принадлежности классу:

class ApiError extends Error { constructor(public code: number) { super() } }

function handle(err: Error | ApiError) {
  if (err instanceof ApiError) {
    console.log(err.code) // err: ApiError
  }
}

in — для проверки наличия свойства, полезен для объединения объектных типов без общего дискриминатора:

interface Cat { meow(): void }
interface Dog { bark(): void }

function makeSound(animal: Cat | Dog) {
  if ('meow' in animal) {
    animal.meow() // animal: Cat
  } else {
    animal.bark() // animal: Dog
  }
}

Discriminated union — паттерн, где у всех вариантов union есть общее литеральное поле ("дискриминатор"), позволяющее TypeScript сужать тип через switch/if по этому полю:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2 // radius доступен
    case 'square': return shape.side ** 2              // side доступен
  }
}

Пользовательские type guards нужны, когда встроенных проверок недостаточно — например, для сложной валидации данных из внешнего источника (API-ответ, unknown). Функция объявляет возвращаемый тип как value is Type: если функция вернула true, TypeScript сужает тип аргумента до Type во всех местах вызова:

interface User { id: number; email: string }

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'email' in value &&
    typeof (value as User).id === 'number'
  )
}

function processInput(data: unknown) {
  if (isUser(data)) {
    console.log(data.email) // data: User — сужено пользовательской проверкой
  }
}

Без value is Type (просто boolean как возвращаемый тип) TypeScript не станет сужать тип аргумента после вызова функции — придётся использовать as, теряя безопасность.

Пример

type ApiResponse =
  | { status: 'success'; data: string[] }
  | { status: 'error'; error: { code: number; message: string } }

function isSuccess(res: ApiResponse): res is Extract<ApiResponse, { status: 'success' }> {
  return res.status === 'success'
}

function handleResponse(res: ApiResponse) {
  // typeof / discriminated union
  if (res.status === 'success') {
    console.log(res.data.join(', '))
    return
  }

  // instanceof для доменных ошибок
  const err = new Error(res.error.message)
  if (err instanceof Error) {
    console.error(`[${res.error.code}] ${err.message}`)
  }
}

// пользовательский type guard, используемый для фильтрации массива
const responses: ApiResponse[] = []
const successOnly = responses.filter(isSuccess) // тип: { status: 'success'; data: string[] }[]

Дополнительные вопросы

  • Чем is в type guard отличается от asserts (function assert(v): asserts v is T)?
  • Как narrowing работает с замыканиями — почему TypeScript "забывает" сужение внутри вложенной функции?
  • Можно ли использовать exhaustiveness checking через never для гарантии обработки всех вариантов union в switch?