ДокументацияTypeScriptType Guards и Type Narrowing в TypeScript
Средний 10 мин чтения

Type Guards и Type Narrowing в TypeScript

Способы сужения типов в TypeScript — typeof, instanceof, in, discriminated unions, пользовательские type guards. Практические примеры и паттерны.

typescripttype guardstype narrowingtypeofinstanceofdiscriminated unions

Что такое Type Narrowing

TypeScript часто знает о переменной меньше, чем существует типов. Например, переменная может быть string | number. Type narrowing (сужение типа) — процесс, при котором TypeScript в блоке кода уточняет тип до более конкретного.

function process(value: string | number) {
  // value: string | number

  if (typeof value === 'string') {
    // value: string — TypeScript сузил тип
    console.log(value.toUpperCase())
  } else {
    // value: number
    console.log(value.toFixed(2))
  }
}

typeof

Самый частый способ сужения. Работает для примитивов:

typeof 'hello'  === 'string'
typeof 42       === 'number'
typeof true     === 'boolean'
typeof undefined === 'undefined'
typeof Symbol() === 'symbol'
typeof BigInt(1) === 'bigint'
typeof (() => {}) === 'function'

Практический пример:

function formatValue(value: string | number | boolean): string {
  if (typeof value === 'string') {
    return value.trim()
  }
  if (typeof value === 'number') {
    return value.toFixed(2)
  }
  return value ? 'да' : 'нет'
}

Ограничение: typeof null === 'object' и typeof [] === 'object'. Для объектов и массивов используйте другие методы.

instanceof

Проверяет, является ли объект экземпляром определённого класса:

function handleError(error: Error | string) {
  if (error instanceof Error) {
    console.log(error.message) // error: Error
  } else {
    console.log(error.toUpperCase()) // error: string
  }
}

Работает с любыми классами:

function formatDate(input: Date | string): string {
  if (input instanceof Date) {
    return input.toLocaleDateString('ru-RU')
  }
  return new Date(input).toLocaleDateString('ru-RU')
}

С DOM-элементами:

function handleElement(el: HTMLElement) {
  if (el instanceof HTMLInputElement) {
    console.log(el.value) // есть только у HTMLInputElement
  }
  if (el instanceof HTMLDivElement) {
    console.log(el.innerHTML)
  }
}

Оператор in

Проверяет наличие свойства в объекте:

interface Dog {
  bark(): void
}

interface Cat {
  meow(): void
}

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

Удобно для различения похожих типов:

interface ApiResponse {
  data: unknown
  error?: string
}

if ('error' in response) {
  console.log(response.error) // string
}

Literal-сужение

Сравнение с конкретным значением сужает тип до литерала:

type Status = 'loading' | 'success' | 'error'

function getStatusIcon(status: Status): string {
  if (status === 'loading') return ''
  if (status === 'success') return ''
  return '' // TypeScript знает, что тут status === 'error'
}

С switch:

function handleStatus(status: Status) {
  switch (status) {
    case 'loading':
      // status: 'loading'
      break
    case 'success':
      // status: 'success'
      break
    case 'error':
      // status: 'error'
      break
    default:
      const _exhaustive: never = status
  }
}

Discriminated Unions

Discriminated union (размеченное объединение) — паттерн, при котором все варианты имеют общее поле (дискриминант) с разными literal-значениями:

interface Circle {
  kind: 'circle'
  radius: number
}

interface Rectangle {
  kind: 'rectangle'
  width: number
  height: number
}

interface Triangle {
  kind: 'triangle'
  base: number
  height: number
}

type Shape = Circle | Rectangle | Triangle

Теперь TypeScript сужает тип по полю kind:

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
  }
}

В каждом case TypeScript знает конкретный тип и подсказывает доступные поля.

Exhaustive check — убедиться, что все варианты обработаны:

function assertNever(value: never): never {
  throw new Error(`Необработанный вариант: ${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)
  }
}

Если добавить новый вариант в Shape и забыть обработать — TypeScript укажет на ошибку.

Пользовательские Type Guards

Type Predicate

Функция-предикат возвращает boolean, но при этом уточняет тип через синтаксис value is Type:

function isString(value: unknown): value is string {
  return typeof value === 'string'
}

function processValue(value: unknown) {
  if (isString(value)) {
    // value: string
    console.log(value.toUpperCase())
  }
}

Пример с массивом — фильтрация null:

const items: (string | null)[] = ['hello', null, 'world', null]
const filtered = items.filter((item): item is string => item !== null)
// filtered: string[]

Без type predicate TypeScript считал бы filtered как (string | null)[].

Проверка для DOM:

function isInputElement(el: HTMLElement): el is HTMLInputElement {
  return el instanceof HTMLInputElement
}

if (isInputElement(target)) {
  console.log(target.value)
}

Assertion Functions

Функция-утверждение через asserts:

function assertDefined<T>(value: T | undefined | null, message?: string): asserts value is T {
  if (value == null) {
    throw new Error(message ?? 'Значение не должно быть null/undefined')
  }
}

const maybeUser: User | undefined = getUser()
assertDefined(maybeUser, 'Пользователь не найден')
// После этой строки: maybeUser: User
console.log(maybeUser.name)

Сужение через Truthiness

TypeScript сужает тип, когда проверяется на truthy/falsy:

function printLength(value: string | null) {
  if (value) {
    // value: string (null исключён)
    console.log(value.length)
  }
}

С optional chaining и nullish coalescing:

const name = user?.name ?? 'Аноним'
// name: string (не string | undefined)

Сужение через присваивание

TypeScript сужает тип при присваивании:

let value: string | number

value = 'hello'
value.toUpperCase() // ok, TypeScript знает что string

value = 42
value.toFixed(2) // ok, TypeScript знает что number

Практические примеры

Обработка ответа API:

interface SuccessResponse<T> {
  status: 'success'
  data: T
}

interface ErrorResponse {
  status: 'error'
  message: string
  code: number
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse

function handleResponse<T>(response: ApiResponse<T>) {
  if (response.status === 'success') {
    console.log('Данные:', response.data)
  } else {
    console.error(`Ошибка ${response.code}: ${response.message}`)
  }
}

Безопасное извлечение из массива:

function assertStringArray(value: unknown): asserts value is string[] {
  if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) {
    throw new TypeError('Ожидается массив строк')
  }
}

const rawData: unknown = JSON.parse('["a", "b", "c"]')
assertStringArray(rawData)
rawData.map(s => s.toUpperCase()) // ok

Проверка типа события:

interface KeyboardEvent {
  type: 'keydown' | 'keyup'
  key: string
}

interface MouseEvent {
  type: 'click' | 'dblclick'
  x: number
  y: number
}

type UIEvent = KeyboardEvent | MouseEvent

function handleEvent(event: UIEvent) {
  switch (event.type) {
    case 'keydown':
    case 'keyup':
      console.log(`Клавиша: ${event.key}`)
      break
    case 'click':
    case 'dblclick':
      console.log(`Позиция: ${event.x}, ${event.y}`)
      break
  }
}

Итог

Type narrowing — механизм, который помогает TypeScript понимать, какой конкретно тип у переменной в данном блоке кода. Основные способы: typeof для примитивов, instanceof для классов, in для проверки свойств, literal-сравнение и discriminated unions. Пользовательские type guards через value is Type нужны для сложных проверок. Exhaustive check через never гарантирует, что все варианты union обработаны.