Урок 21: Пользовательские type guards (is operator) в TypeScript

Приветствую тебя на очередном, уже 21-м уроке нашего курса по TypeScript! Сегодня мы разберем одну из моих самых любимых и невероятно мощных возможностей TypeScript, это пользовательские type guards (защитники типов), а конкретнее, волшебный оператор is.

Это тот инструмент, который отделяет простое использование TypeScript от профессионального владения им. Он позволяет нам не просто описывать типы, а активно помогать TypeScript понимать, что происходит в нашем коде, делая его одновременно и гибким и максимально безопасным. Если ты когда-либо чувствовал, что TypeScript «не догоняет», что переменная в условии уже проверена. Этот урок раз и навсегда даст тебе в руки инструмент для решения этой проблемы.

Проблема: TypeScript не всегда такой догадливый, как хотелось бы

Давай начнем с классической проблемы, с которой ты наверняка уже сталкивался. Представь, у нас есть функция, которая может работать с разными типами данных.

typescript
function formatValue(value: string | number) {
    // Мы хотим для строки вызвать .toUpperCase(), а для числа - .toFixed()
    if (typeof value === 'string') {
        // Здесь TypeScript уверен, что value - это string!
        console.log(value.toUpperCase());
    } else if (typeof value === 'number') {
        // А здесь TypeScript уверен, что value - это number!
        console.log(value.toFixed(2));
    } else {
        // А здесь value - это never, потому что других вариантов нет.
        console.log('Неизвестный тип');
    }
}

С встроенными типами, такими как stringnumberbooleansymbol и т.д., TypeScript справляется блестяще благодаря оператору typeof. Для проверки на массив мы можем использовать Array.isArray(), для проверки на null или undefined,  простые сравнения (value === null).

Но что, если мы работаем не с примитивами, а с нашими собственными, сложными объектами? Вот где начинается настоящая боль.

Допустим, у нас есть два типа пользователей:

typescript
interface Admin {
    name: string;
    role: string; // Уникальное свойство для Admin
    securityLevel: number;
}

interface RegularUser {
    name: string;
    email: string; // Уникальное свойство для RegularUser
    lastLogin: Date;
}

type User = Admin | RegularUser;

Теперь попробуем написать функцию, которая по-разному приветствует админа и обычного пользователя.

typescript
function greetUser(user: User) {
    if (typeof user === 'Admin') { // ОШИБКА! Так low-level typeof не работает с интерфейсами.
        console.log(`Здравствуйте, администратор ${user.name}! Ваша роль: ${user.role}`);
    }
}

Это не сработает. typeof во время выполнения (runtime) вернет "object" для обоих типов, он не умеет различать наши TypeScript-интерфейсы.

Мы можем попробовать проверить наличие уникального свойства:

typescript
function greetUser(user: User) {
    if ('role' in user) {
        // Кажется, что мы проверили и теперь это Admin...
        console.log(`Здравствуйте, администратор ${user.name}! Ваша роль: ${user.role}`);
    } else if ('email' in user) {
        console.log(`Привет, ${user.name}! Твой email: ${user.email}`);
    }
}

Вроде бы работает. Но у этого подхода есть несколько скрытых проблем:

  1. Ненадежность. Что если в будущем в RegularUser добавят свойство role (например, «роль в сообществе»), но это не будет означать, что он Admin? Наша проверка сломается.

  2. Плохая масштабируемость. Если типов будет не два, а десять, код превратится в спагетти из проверок in.

  3. «Грязный» код. Логика проверки типа размазана внутри функции, отвечающей за приветствие. Это нарушает принцип единственной ответственности.

И самое главное, посмотри внимательно на блок if ('role' in user). TypeScript здесь сузит тип user до Admin и это здорово. Но представь, что проверка сложная и должна использоваться в десятке мест по всему коду. Мы будем везде копипастить это условие 'role' in user? А если надо будет изменить логику проверки? Кошмар!

Именно здесь на сцену выходят наши спасители, пользовательские type guards.

Решение: Создаем свою функцию-предикат с parameter is Type

Пользовательский type guard это просто функция, которая возвращает не boolean, а специальный тип-предикат вида parameterName is Type.

Давай перепишем наш пример правильно.

typescript
// Это и есть наш кастомный type guard!
function isAdmin(user: User): user is Admin {
    return 'role' in user; // Пока что упрощенно. Позже улучшим!
}

function isRegularUser(user: User): user is RegularUser {
    return 'email' in user; // И так же тут.
}

Обрати внимание на возвращаемый тип: user is Admin. Эта аннотация говорит TypeScript: «Эй, парень, если эта функция вернет true, то ты можешь быть на 100% уверен, что переданный объект user имеет тип Admin«.

Теперь наша функция greetUser становится невероятно чистой и читаемой:

typescript
function greetUser(user: User) {
    if (isAdmin(user)) {
        // TypeScript ЗНАЕТ, что здесь user - это Admin
        console.log(`Здравствуйте, администратор ${user.name}! Ваша роль: ${user.role}`);
    } else if (isRegularUser(user)) {
        // TypeScript ЗНАЕТ, что здесь user - это RegularUser
        console.log(`Привет, ${user.name}! Твой email: ${user.email}`);
    } else {
        // А здесь, благодаря контролю типов, это будет never
        const _exhaustiveCheck: never = user;
        throw new Error('Неизвестный тип пользователя');
    }
}

Красота, не правда ли? Мы вынесли логику проверки в отдельные функции, дали им понятные имена и теперь основная логика функции greetUser кристально чиста. Мы можем легко переиспользовать isAdmin и isRegularUser в любом другом месте нашей программы.

Как это работает под капотом?

Важно понять философию этого механизма. Type Guard это по сути договор между тобой и TypeScript. Ты говоришь компилятору: «Я, как разработчик, беру на себя ответственность. Если моя функция вернет true, то можешь доверять мне и считать аргумент указанным типом».

Это одновременно и сила и опасность. TypeScript слепо верит твоим type guards. Если ты напишешь некорректную логику внутри guard’a, TypeScript не сможет это обнаружить и будет вести себя непредсказуемо, приводя к ошибкам во время выполнения. Вся ответственность за корректность проверки лежит на тебе.

Поэтому создавать guards нужно очень аккуратно и обдуманно.

Пишем надежные type guards

Наши первые guards были слишком наивными. Проверка 'role' in user может быть обманчивой. Давай сделаем их более надежными. Хорошая практика, проверять не просто наличие свойства, а его тип (насколько это возможно в runtime).

typescript
function isAdmin(user: User): user is Admin {
    // Проверяем, что объект существует, свойство 'role' есть и оно является строкой.
    return user && 'role' in user && typeof (user as any).role === 'string';
    // Примечание: Приведение к `any` нужно, чтобы избежать ошибки компиляции
    // до того, как мы проверили тип. Это безопасно внутри guard.
}

function isRegularUser(user: User): user is RegularUser {
    return user && 'email' in user && typeof (user as any).email === 'string';
}

Это уже лучше. Но что если у наших объектов есть общие свойства? Идеальный guard должен проверять уникальный набор характеристик, которые с очень высокой вероятностью гарантируют, что мы имеем дело с нужным типом.

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

Дискриминантное свойство (Discriminant Property) это свойство, которое есть у всех членов юниона, но с разным литеральным значением. Это идеальный и абсолютно надежный способ различения типов.

Давай переопределим наши типы, чтобы использовать этот паттерн.

typescript
interface Admin {
    type: 'admin'; // Дискриминантное свойство с литеральным типом 'admin'
    name: string;
    role: string;
    securityLevel: number;
}

interface RegularUser {
    type: 'user'; // Дискриминантное свойство с литеральным типом 'user'
    name: string;
    email: string;
    lastLogin: Date;
}

type User = Admin | RegularUser;

Теперь наши guards становятся простыми, надежными и элегантными:

typescript
function isAdmin(user: User): user is Admin {
    return user.type === 'admin';
}

function isRegularUser(user: User): user is RegularUser {
    return user.type === 'user';
}

Вот оно! Золотой стандарт! Такая проверка неоспорима и не сломается при расширении типов. TypeScript также отлично понимает такие проверки и сам может сужать типы даже без явных guard’ов, но выносить их в функции все равно полезно для переиспользования и чистоты кода.

Более сложные и практические примеры

Давай рассмотрим примеры, которые чаще всего встречаются в реальной практике.

Пример 1: Проверка на определенный тип объекта

Часто бывает нужно проверить, что объект соответствует определенной структуре (как бы «уткакатипизация», если объект ходит как утка и крякает как утка, то это probably утка).

typescript
interface Cat {
    meow(): void;
}

interface Dog {
    bark(): void;
}

function isCat(animal: any): animal is Cat {
    // Проверяем, что animal существует и у него есть метод meow
    return animal && typeof animal.meow === 'function';
}

function makeSound(animal: Cat | Dog) {
    if (isCat(animal)) {
        animal.meow(); // TypeScript знает, что это Cat
    } else {
        animal.bark(); // TypeScript знает, что это Dog
    }
}

Пример 2: Работа с массивами разных типов

Допустим, у нас есть массив, который может содержать либо числа, либо строки и мы хотим отфильтровать только числа.

typescript
type StringOrNumber = string | number;

function isNumberArray(value: Array<StringOrNumber>): value is number[] {
    // Проверяем, что каждый элемент массива - число
    return value.every(item => typeof item === 'number');
}

const data: StringOrNumber[] = [1, 'hello', 42, 'world', 100];

if (isNumberArray(data)) {
    // Теперь TypeScript считает, что data - это number[]
    const sum = data.reduce((acc, num) => acc + num, 0); // Можно безопасно складывать
    console.log(sum);
} else {
    console.log('Массив содержит не только числа, сложение невозможно.');
}

Пример 3: Проверка на определенный класс (если мы их используем)

typescript
class Car {
    drive() {
        console.log('Vroom vroom!');
    }
}

class Boat {
    sail() {
        console.log('Swish swish!');
    }
}

function isCar(vehicle: Car | Boat): vehicle is Car {
    // Проверяем, что vehicle является экземпляром Car
    // Это надежнее, чем проверка на наличие метода drive
    return vehicle instanceof Car;
}

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

Практические задачи для закрепления

Давай закрепим материал на реальных задачах.

Задача 1: «Валидатор формы»

У нас есть данные формы, которые могут быть либо в состоянии «успешно отправлены», либо в состоянии «ошибка валидации».

typescript
interface FormSuccess {
    status: 'success';
    data: {
        id: number;
        username: string;
    };
}

interface FormError {
    status: 'error';
    errors: Array<{
        field: string;
        message: string;
    }>;
}

type FormResponse = FormSuccess | FormError;

Задание: Напиши type guard функцию isFormSuccess(response: FormResponse): response is FormSuccess. Затем используй ее в функции handleResponse(response: FormResponse), которая выводит в консоль ID пользователя в случае успеха или список ошибок в случае неудачи.

Решение задачи 1:

typescript
// Type Guard
function isFormSuccess(response: FormResponse): response is FormSuccess {
    return response.status === 'success';
}

// Функция-обработчик
function handleResponse(response: FormResponse) {
    if (isFormSuccess(response)) {
        console.log(`Успех! ID пользователя: ${response.data.id}`);
    } else {
        console.log('Ошибки валидации:');
        response.errors.forEach(err => console.log(`- ${err.field}: ${err.message}`));
    }
}

// Пример использования
const successResponse: FormResponse = {
    status: 'success',
    data: { id: 123, username: 'Maxim' }
};

const errorResponse: FormResponse = {
    status: 'error',
    errors: [
        { field: 'email', message: 'Invalid email' },
        { field: 'password', message: 'Password is too short' }
    ]
};

handleResponse(successResponse); // Успех! ID пользователя: 123
handleResponse(errorResponse); // Ошибки валидации: ...

</details>

Задача 2: «Парсер API ответа»

Представь, что ты получаешь ответ от внешнего API. Ты не уверен на 100% в его структуре (потому что API может измениться), но у тебя есть ожидаемый тип.

typescript
interface ApiUser {
    id: number;
    name: string;
    email: string;
}

// Мы ожидаем, что ответ будет массивом пользователей, но нужно это проверить.
// Фактически, мы получаем any от fetch-запроса.
async function fetchUsers(): Promise<ApiUser[]> {
    const response = await fetch('https://api.example.com/users');
    const data: unknown = await response.json(); // Данные неизвестной структуры

    // Задание: Напиши type guard `isApiUsersArray`,
    // который проверит, что data является массивом объектов,
    // и каждый объект в нем имеет свойства id (number), name (string), email (string).
    if (isApiUsersArray(data)) {
        return data;
    } else {
        throw new Error('Получены некорректные данные от сервера');
    }
}

Задание: Напиши функцию isApiUsersArray(data: unknown): data is ApiUser[].

Решение задачи 2:

typescript
function isApiUsersArray(data: unknown): data is ApiUser[] {
    // 1. Проверяем, что data является массивом
    if (!Array.isArray(data)) {
        return false;
    }

    // 2. Проверяем каждый элемент массива
    return data.every(item => {
        // 3. Проверяем, что item - объект и имеет нужные свойства правильного типа
        return (
            typeof item === 'object' &&
            item !== null &&
            'id' in item &&
            typeof (item as any).id === 'number' &&
            'name' in item &&
            typeof (item as any).name === 'string' &&
            'email' in item &&
            typeof (item as any).email === 'string'
        );
    });
}

Это надежный, хотя и немного многословный guard. В реальных проектах для таких сложных валидаций часто используют библиотеки вроде Zod или Yup, которые автоматически генерируют и типы и runtime-валидаторы. Но понимание, как это работает на низком уровне, бесценно.

</details>

Заключение и выводы

Вот и подошел к концу наш 21-й урок. Сегодня мы с тобой разобрали одну из самых крутых тем, пользовательские type guards. Давай резюмируем главное:

  1. Мы научились помогать TypeScript сужать типы в сложных случаях, особенно когда работаем с нашими кастомными типами (интерфейсами, типами-объединениями).

  2. Как это работает. Мы создаем функцию, которая возвращает не просто boolean, а тип-предикат вида parameterName is SpecificType.

  3. Власть = ответственность: TypeScript безоговорочно верит нашему guard’у. Если наша логика проверки ошибочна, это приведет к ошибкам во время выполнения. Поэтому guards должны быть максимально надежными.

  4. Использование дискриминантных свойств (общих свойств с литеральными типами), это лучший и самый надежный способ различения типов в union’e.

  5. Guards незаменимы для валидации данных извне (ответы API, ввод пользователя), для чистоты кода и его переиспользования.

Этот инструмент кардинально меняет то, как ты подходишь к типизации сложных логических потоков в приложении. Он делает твой код не только безопаснее, но и гораздо выразительнее и легче для чтения и поддержки.

В следующих урокпх мы продолжим углубляться в систему типов TypeScript и поговорим о еще более продвинутых техниках.

Полный курс с уроками по TypeScript для начинающих можно найти по адресу: https://max-gabov.ru/typescript-dlya-nachinaushih

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

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

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