← Курс/Type Guards и сужение типов#170 из 257+30 XP

Type Guards и сужение типов (Type Narrowing)

Что такое type narrowing

TypeScript **сужает** (narrows) тип переменной после проверки. В разных ветках кода переменная имеет разные типы:

function process(value: string | number) {
  // Здесь value: string | number
  if (typeof value === 'string') {
    // Здесь value: string — TypeScript знает это!
    return value.toUpperCase()
  }
  // Здесь value: number
  return value.toFixed(2)
}

typeof guard

Работает для примитивов: string, number, boolean, bigint, symbol, function, undefined. Но typeof null === 'object' — исторический баг JavaScript!

function stringify(value: unknown): string {
  if (typeof value === 'string')    return value
  if (typeof value === 'number')    return value.toString()
  if (typeof value === 'boolean')   return value ? 'true' : 'false'
  if (typeof value === 'undefined') return 'undefined'
  return JSON.stringify(value)
}

instanceof guard

Для классов и объектов встроенных типов:

function formatDate(value: Date | string): string {
  if (value instanceof Date) {
    return value.toLocaleDateString()  // value: Date
  }
  return new Date(value).toLocaleDateString()  // value: string
}

function handleError(err: unknown): string {
  if (err instanceof Error) {
    return err.message  // err: Error — есть .message
  }
  return String(err)
}

in operator

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

type Cat = { name: string; meow(): void }
type Dog = { name: string; bark(): void }

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

Custom type guard функции

Функция, которая возвращает value is Type — сообщает TypeScript о сужении:

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

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'name' in value &&
    typeof (value as any).name === 'string'
  )
}

// Использование:
function greet(value: unknown) {
  if (isUser(value)) {
    console.log(value.name)  // TypeScript знает: value.name: string
  }
}

Discriminated union guard

Самый надёжный способ — поле-тег:

type Result<T> =
  | { success: true;  value: T }
  | { success: false; error: string }

function handleResult<T>(result: Result<T>) {
  if (result.success) {
    console.log(result.value)  // result: { success: true, value: T }
  } else {
    console.error(result.error)  // result: { success: false, error: string }
  }
}

Truthiness narrowing

function processName(name: string | null | undefined) {
  if (name) {
    // name: string — null и undefined исключены
    return name.toUpperCase()
  }
  return 'Аноним'
}

Осторожно: пустая строка '' тоже falsy!

Когда что использовать

| Сценарий | Guard |

|---|---|

| Примитивы (string/number/boolean) | typeof |

| Классы, Date, Error, Array | instanceof |

| Объекты с разными полями | in |

| Сложная проверка структуры | custom type guard |

| Объекты с общим полем-тегом | discriminated union |

| null/undefined | truthiness / === null |

Примеры

Набор type guard функций и parseUserInput для обработки разных форматов входных данных

// Type guard функции — в TypeScript возвращали бы val is string и т.д.

function isString(val) {
  return typeof val === 'string'
}

function isNumber(val) {
  return typeof val === 'number' && !isNaN(val)
}

function isBoolean(val) {
  return typeof val === 'boolean'
}

function isArray(val) {
  return Array.isArray(val)
}

function isObject(val) {
  return typeof val === 'object' && val !== null && !Array.isArray(val)
}

function isNullish(val) {
  return val === null || val === undefined
}

function isFunction(val) {
  return typeof val === 'function'
}

function isDate(val) {
  return val instanceof Date && !isNaN(val.getTime())
}

// Кастомный guard для User-подобного объекта
function isUserLike(val) {
  return isObject(val) && isString(val.name)
}

// Функция parseUserInput использует все guards
function parseUserInput(input) {
  if (isNullish(input)) {
    return { type: 'empty', value: null, display: '(пусто)' }
  }

  if (isBoolean(input)) {
    return { type: 'boolean', value: input, display: input ? 'да' : 'нет' }
  }

  if (isNumber(input)) {
    return {
      type: 'number',
      value: input,
      display: input.toLocaleString('ru-RU'),
    }
  }

  if (isString(input)) {
    // Попробуем распарсить как число
    const parsed = Number(input)
    if (!isNaN(parsed) && input.trim() !== '') {
      return { type: 'number-string', value: parsed, display: String(parsed) }
    }
    // Попробуем распарсить как дату
    const date = new Date(input)
    if (!isNaN(date.getTime()) && input.includes('-')) {
      return {
        type: 'date-string',
        value: date,
        display: date.toLocaleDateString('ru-RU'),
      }
    }
    return { type: 'string', value: input, display: `"${input}"` }
  }

  if (isDate(input)) {
    return {
      type: 'date',
      value: input,
      display: input.toLocaleDateString('ru-RU'),
    }
  }

  if (isArray(input)) {
    return {
      type: 'array',
      value: input,
      display: `[${input.length} элем.]`,
    }
  }

  if (isUserLike(input)) {
    return {
      type: 'user',
      value: input,
      display: `User: ${input.name}${input.age ? `, ${input.age} лет` : ''}`,
    }
  }

  if (isObject(input)) {
    return {
      type: 'object',
      value: input,
      display: `{keys: ${Object.keys(input).join(', ')}}`,
    }
  }

  if (isFunction(input)) {
    return { type: 'function', value: input, display: `fn(${input.name})` }
  }

  return { type: 'unknown', value: input, display: String(input) }
}

// --- Демонстрация ---

const inputs = [
  null,
  undefined,
  true,
  false,
  42,
  3.14,
  'hello',
  '123',
  '2024-01-15',
  new Date(),
  [1, 2, 3],
  { name: 'Алексей', age: 30 },
  { x: 1, y: 2 },
  Math.max,
]

console.log('=== parseUserInput для разных значений ===')
inputs.forEach(input => {
  const result = parseUserInput(input)
  console.log(`[${result.type}] ${result.display}`)
})