Урок 20: Type Guard и сужение типов (Narrowing) в TypeScript

Сегодня нас ждет один из самых важных и практичных уроков во всем курсе. Мы будем говорить о механизме, который лежит в самой основе того, как TypeScript понимает наш код и обеспечивает его безопасность. Речь пойдет о Сужении типов (Narrowing) и Защитниках типов (Type Guards).

До этого мы часто сталкивались с ситуациями, когда у переменной могло быть несколько типов (объединение или union type). Например, string | number. И когда мы пытались выполнить операцию, специфичную для строки, TypeScript тут же жаловался: «Эй, это может быть и number, я не уверен!». Сужение типов это именно тот процесс, с помощью которого мы говорим TypeScript: «Не волнуйся, в этой конкретной точке кода переменная точно имеет такой-то тип». Давайте же узнаем, как это делается.

Как TypeScript сужает типы внутри условий

Представьте себя на месте TypeScript. Вы видите переменную value: string | number. Вы умный компилятор, но вы не можете заглянуть в будущее и узнать, что же придет в переменную во время выполнения программы. Вы должны быть уверены на 100%, что код безопасен.

Единственный способ сделать это, проанализировать поток управления программой (control flow analysis). TypeScript отслеживает все проверки, условия, присваивания и на их основе делает выводы о типе переменной в определенных блоках кода. Этот процесс и называется сужением типа.

Сужение, это переход от более общего типа (например, string | number) к более конкретному (например, string) в определенной области видимости. TypeScript проделывает это автоматически, когда встречает конструкции, которые он может проанализировать: ifswitch, тернарные операторы, циклы, проверки на истинность и т.д.

Давайте рассмотрим простейший пример без всяких специальных операторов:

typescript
function printId(id: number | string) {
  if (typeof id === 'string') {
    // В этой ветке TypeScript знает, что `id` это строка.
    // Поэтому мы можем безопасно вызывать методы строк.
    console.log(id.toUpperCase()); // OK!
  } else {
    // А в этой ветке TypeScript понимает, что `id` это не строка.
    // Поскольку изначально тип был только string | number,
    // значит здесь `id` это number.
    console.log(id.toFixed(2)); // OK!
  }
}

Вот как это работает: зайдя в блок if, TypeScript сузил тип id с string | number до string. Соответственно, в блоке else остался только тип number. Это и есть сужение в своей самой простой и мощной форме.

Теперь давайте изучим конкретные инструменты, которые мы можем использовать для управления этим процессом.

Использование typeof для проверки примитивных типов

Первый и самый частый инструмент в нашем арсенале это оператор typeof. Он идеально подходит для проверки примитивных типов JavaScript.

В TypeScript typeof возвращает следующие строковые значения: "string""number""boolean""symbol""undefined""object""function".

Важное замечание: Обратите внимание, что для null typeof возвращает "object". Это известное поведение JavaScript и оно означает, что мы не можем использовать typeof для проверки на null!

Давайте посмотрим на практическом примере, как typeof помогает нам сужать типы.

typescript
function processValue(value: string | number | boolean) {
  if (typeof value === 'string') {
    console.log(`Строка в верхнем регистре: ${value.toUpperCase()}`);
  } else if (typeof value === 'number') {
    console.log(`Число с двумя знаками после запятой: ${value.toFixed(2)}`);
  } else {
    // Здесь TypeScript понимает, что остался только boolean
    console.log(`Это булево значение: ${value}`);
  }
}

processValue('Hello'); // Строка в верхнем регистре: HELLO
processValue(3.14159); // Число с двумя знаками после запятой: 3.14
processValue(true); // Это булево значение: true

А вот пример, который демонстрирует ограничение с null:

typescript
function getValue(x: string | null) {
  if (typeof x === 'object') {
    // ПЛОХО! TypeScript сузил тип к `string | null`,
    // потому что typeof null тоже 'object'!
    console.log(x); // x: string | null
    // Мы никуда не продвинулись и все еще можем получить ошибку.
  }
}

Для проверки на null или undefined мы используем другие методы, о которых поговорим дальше. Запомните: typeof отличный инструмент для примитивов, но не для проверки на null.

Практическая задача №1

Напишите функцию calculate, которая принимает два аргумента a и b. Каждый аргумент может быть либо числом, либо строкой, содержащей число (например, '5'). Функция должна возвращать сумму a и b как число. Используйте typeof и сужение типов, чтобы преобразовать строки в числа перед сложением.

Решение:

typescript
function calculate(a: number | string, b: number | string): number {
  // Сужаем тип a и преобразуем его к number при необходимости
  const numA = typeof a === 'string' ? parseFloat(a) : a;
  
  // Сужаем тип b и преобразуем его к number при необходимости
  const numB = typeof b === 'string' ? parseFloat(b) : b;

  return numA + numB;
}

console.log(calculate(10, '5.5')); // 15.5
console.log(calculate('20', '30')); // 50

Использование instanceof для проверки экземпляров классов

Если typeof отлично работает с примитивами, то для проверки принадлежности объекта к определенному классу или конструктору мы используем оператор instanceof.

Оператор instanceof проверяет, присутствует ли в цепочке прототипов объекта определенный класс. Это мощный способ отличить, скажем, массив от даты или ваш собственный класс User от класса Product.

typescript
class Dog {
  bark() {
    console.log('Woof!');
  }
}

class Cat {
  meow() {
    console.log('Meow!');
  }
}

function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    // TypeScript сузил тип до Dog, поэтому метод bark доступен.
    animal.bark(); // OK!
  } else {
    // TypeScript понимает, что здесь animal - это Cat.
    animal.meow(); // OK!
  }
}

const myDog = new Dog();
const myCat = new Cat();

makeSound(myDog); // Woof!
makeSound(myCat); // Meow!

instanceof отлично работает и со встроенными классами JavaScript, такими как ArrayDateRegExpMapSet и т.д.

typescript
function logValue(value: Date | RegExp | number[]) {
  if (value instanceof Date) {
    console.log(`Это Дата: ${value.toISOString()}`);
  } else if (value instanceof RegExp) {
    console.log(`Это Регулярное выражение: ${value.source}`);
  } else {
    // TypeScript сузил тип до number[]
    console.log(`Это Массив чисел: ${value.join(', ')}`);
  }
}

logValue(new Date()); // Это Дата: 2023-10-05T12:34:56.789Z
logValue(/test/gi); // Это Регулярное выражение: test
logValue([1, 2, 3]); // Это Массив чисел: 1, 2, 3

Практическая задача №2

Создайте два класса: Circle (с методом calculateArea(), возвращающим площадь по радиусу) и Rectangle (с методом calculateArea(), возвращающим площадь по ширине и высоте). Напишите функцию getArea(shape: Circle | Rectangle), которая использует instanceof для сужения типа и вызывает правильный метод для вычисления площади.

Решение:

typescript
class Circle {
  constructor(public radius: number) {}

  calculateArea(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle {
  constructor(public width: number, public height: number) {}

  calculateArea(): number {
    return this.width * this.height;
  }
}

function getArea(shape: Circle | Rectangle): number {
  if (shape instanceof Circle) {
    // shape сужено до Circle
    return shape.calculateArea();
  } else {
    // shape сужено до Rectangle
    return shape.calculateArea();
  }
}

const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);

console.log(getArea(circle)); // ~78.54
console.log(getArea(rectangle)); // 24

Проверки на равенство (===!====!=) и проверка на truthy/falsy

Часто бывает нужно проверить переменную на конкретное значение. Особенно это полезно для работы с литеральными типами (literal types) и с null/undefined.

Проверка на null и undefined

Вот где проверки на равенство проявляют себя во всей красе. Помните проблему typeof null? Здесь ее можно легко решить.

typescript
function greet(name: string | null) {
  if (name === null) {
    console.log('Привет, гость!');
  } else {
    // TypeScript точно знает, что здесь name - это string
    console.log(`Привет, ${name.toUpperCase()}!`);
  }
}

greet('Максим'); // Привет, МАКСИМ!
greet(null); // Привет, гость!

Аналогично работает с undefined. TypeScript также понимает проверки на равенство с конкретными значениями.

typescript
type Direction = 'left' | 'right' | 'up' | 'down';

function move(direction: Direction | null) {
  if (direction === 'left') {
    console.log('Двигаемся влево');
  } else if (direction === 'right') {
    console.log('Двигаемся вправо');
  } else if (direction === null) {
    console.log('Остаемся на месте');
  } else {
    // Сужено до 'up' | 'down'
    console.log('Двигаемся по вертикали');
  }
}

Проверки на truthy/falsy

Иногда нам не важно, какое именно значение, а важно, есть ли оно вообще. Мы можем использовать саму переменную в условии, чтобы проверить, является ли она truthy (не false0""nullundefinedNaN).

typescript
function printAll(strs: string | string[] | null) {
  // Проверяем, не является ли strs falsy значением (включая null и пустую строку)
  if (strs && typeof strs === 'object') {
    // strs сужено до string[] (но не пустой массив!)
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === 'string') {
    // strs сужено до string
    console.log(strs);
  } else {
    // strs сужено до null или другого falsy
    console.log('Нет данных');
  }
}

printAll(['A', 'B', 'C']); // A, B, C
printAll('Hello'); // Hello
printAll(''); // Пустая строка (falsy), но тип string! Попадет во вторую ветку.
printAll(null); // Нет данных

Важно: Будьте осторожны с 0 и пустой строкой "". Они являются falsy, но это валидные значения, которые иногда нужно обрабатывать отдельно.

Практическая задача №3

Напишите функцию getLength, которая принимает аргумент типа string | number | null | undefined. Функция должна возвращать длину строки или количество цифр в числе. Если аргумент null или undefined, функция должна возвращать 0. Используйте проверки на равенство для обработки null и undefined.

Решение:

typescript
function getLength(input: string | number | null | undefined): number {
  if (input === null || input === undefined) {
    return 0;
  }

  // После проверки на null/undefined, input сужен до string | number
  if (typeof input === 'string') {
    return input.length;
  } else {
    // input сужен до number
    return input.toString().length;
  }
}

console.log(getLength('Hello')); // 5
console.log(getLength(12345)); // 5
console.log(getLength(null)); // 0
console.log(getLength(undefined)); // 0

Пользовательские type guards (Защитники типов)

А что, если встроенных проверок недостаточно? Например, мы хотим проверить, что объект имеет определенную структуру (проверить наличие свойства). Или у нас сложная логика валидации. Для этого TypeScript позволяет нам создавать собственные Защитники типов (User-Defined Type Guards).

Пользовательский защитник типа это функция, которая возвращает булево значение и имеет специальную сигнатуру возвращаемого типа: arg is Type.

Эта сигнатура is и есть волшебство. Она говорит TypeScript: «Если эта функция вернет true, то переданный аргумент имеет указанный тип Type«.

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

Самый частый случай, это проверка, что у объекта есть определенное свойство (проверка на существование).

typescript
// Допустим, у нас есть два типа
type Fish = { swim: () => void };
type Bird = { fly: () => void };

// Наш пользовательский type guard
function isFish(pet: Fish | Bird): pet is Fish {
  // Мы проверяем, есть ли у объекта метод swim.
  // Приведение (pet as Fish) нужно, чтобы TypeScript не ругался на возможное отсутствие свойства.
  return (pet as Fish).swim !== undefined;
}

function howToMove(pet: Fish | Bird) {
  if (isFish(pet)) {
    // TypeScript доверяет нашей функции isFish и сужает тип до Fish.
    pet.swim(); // OK!
  } else {
    // Следовательно, здесь тип сужен до Bird.
    pet.fly(); // OK!
  }
}

const myFish: Fish = { swim: () => console.log('Плыву!') };
const myBird: Bird = { fly: () => console.log('Лечу!') };

howToMove(myFish); // Плыву!
howToMove(myBird); // Лечу!

Более сложная валидация

Защитники типов могут содержать любую логику. Например, проверку на число в строке.

typescript
function isNumberString(value: any): value is string {
  // Проверяем, что value - это строка И что она преобразуется в корректное число
  return typeof value === 'string' && !isNaN(parseFloat(value));
}

function processInput(input: string | number) {
  if (isNumberString(input)) {
    // TypeScript знает, что input - string, но наша guard-функция
    // дала ему больше уверенности. Хотя на самом деле тип все еще string.
    // Чаще это используется для более сложных структур.
    const num = parseFloat(input);
    console.log(`Число из строки: ${num}`);
  } else if (typeof input === 'number') {
    console.log(`Изначальное число: ${input}`);
  }
}

processInput('123'); // Число из строки: 123
processInput(456); // Изначальное число: 456
processInput('abc'); // Не пройдет проверку isNumberString, но и не number. Ничего не выведет.

Практическая задача №4

Создайте тип Car (свойство type: 'car' и метод drive()) и тип Boat (свойство type: 'boat' и метод sail()). Напишите пользовательский защитник типа isCar, который проверяет, является ли объект машинкой. Затем напишите функцию useVehicle, которая использует этот защитник для вызова правильного метода.

Решение:

typescript
interface Car {
  type: 'car';
  drive: () => void;
}

interface Boat {
  type: 'boat';
  sail: () => void;
}

// Пользовательский type guard
function isCar(vehicle: Car | Boat): vehicle is Car {
  // Проверяем наличие свойства и его значение
  return vehicle.type === 'car';
}

function useVehicle(vehicle: Car | Boat) {
  if (isCar(vehicle)) {
    vehicle.drive();
  } else {
    vehicle.sail();
  }
}

const myCar: Car = {
  type: 'car',
  drive: () => console.log('Машина едет по дороге')
};

const myBoat: Boat = {
  type: 'boat',
  sail: () => console.log('Лодка плывет по воде')
};

useVehicle(myCar); // Машина едет по дороге
useVehicle(myBoat); // Лодка плывет по воде

Комбинирование подходов и заключение

В реальном коде вы будете часто комбинировать все эти подходы для создания надежной и понятной системы типов.

typescript
function processInput(input: unknown) {
  // 1. Сначала проверим, не null ли и не undefined ли это
  if (input == null) {
    console.log('Нет данных');
    return;
  }

  // 2. input теперь сужен от unknown к чему-то, что не null/undefined
  if (typeof input === 'string') {
    console.log(`Строка: ${input.trim()}`);
  } else if (typeof input === 'number') {
    console.log(`Число: ${input.toFixed(2)}`);
  } else if (Array.isArray(input)) { // Array.isArray - это встроенный type guard!
    console.log(`Массив: ${input.length} элементов`);
  } else if (isCustomType(input)) { // Наш пользовательский guard
    console.log(`Кастомный тип: ${input.customValue}`);
  }
  // ... и так далее
}

Используйте typeof и instanceof для простых случаев, проверки на равенство для null и литеральных типов и создавайте собственные защитники типов для сложной валидации объектов.

Удачи в изучении и до встречи на следующих уроках, где мы углубимся в работу с Общими типами (Generics)!

Это был урок №20 из моего полного курса по TypeScript для начинающих. Хотите освоить язык от основ до продвинутых тем? Переходите по ссылке и начните обучение прямо сейчас!

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий