Приветствую тебя на очередном, уже 21-м уроке нашего курса по TypeScript! Сегодня мы разберем одну из моих самых любимых и невероятно мощных возможностей TypeScript, это пользовательские type guards (защитники типов), а конкретнее, волшебный оператор is.
Это тот инструмент, который отделяет простое использование 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('Неизвестный тип'); } }
С встроенными типами, такими как string, number, boolean, symbol и т.д., TypeScript справляется блестяще благодаря оператору typeof. Для проверки на массив мы можем использовать Array.isArray(), для проверки на null или undefined, простые сравнения (value === null).
Но что, если мы работаем не с примитивами, а с нашими собственными, сложными объектами? Вот где начинается настоящая боль.
Допустим, у нас есть два типа пользователей:
interface Admin { name: string; role: string; // Уникальное свойство для Admin securityLevel: number; } interface RegularUser { name: string; email: string; // Уникальное свойство для RegularUser lastLogin: Date; } type User = Admin | RegularUser;
Теперь попробуем написать функцию, которая по-разному приветствует админа и обычного пользователя.
function greetUser(user: User) { if (typeof user === 'Admin') { // ОШИБКА! Так low-level typeof не работает с интерфейсами. console.log(`Здравствуйте, администратор ${user.name}! Ваша роль: ${user.role}`); } }
Это не сработает. typeof во время выполнения (runtime) вернет "object" для обоих типов, он не умеет различать наши 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}`); } }
Вроде бы работает. Но у этого подхода есть несколько скрытых проблем:
-
Ненадежность. Что если в будущем в
RegularUserдобавят свойствоrole(например, «роль в сообществе»), но это не будет означать, что онAdmin? Наша проверка сломается. -
Плохая масштабируемость. Если типов будет не два, а десять, код превратится в спагетти из проверок
in. -
«Грязный» код. Логика проверки типа размазана внутри функции, отвечающей за приветствие. Это нарушает принцип единственной ответственности.
И самое главное, посмотри внимательно на блок if ('role' in user). TypeScript здесь сузит тип user до Admin и это здорово. Но представь, что проверка сложная и должна использоваться в десятке мест по всему коду. Мы будем везде копипастить это условие 'role' in user? А если надо будет изменить логику проверки? Кошмар!
Именно здесь на сцену выходят наши спасители, пользовательские type guards.
Решение: Создаем свою функцию-предикат с parameter is Type
Пользовательский type guard это просто функция, которая возвращает не boolean, а специальный тип-предикат вида parameterName is Type.
Давай перепишем наш пример правильно.
// Это и есть наш кастомный 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 становится невероятно чистой и читаемой:
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).
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) это свойство, которое есть у всех членов юниона, но с разным литеральным значением. Это идеальный и абсолютно надежный способ различения типов.
Давай переопределим наши типы, чтобы использовать этот паттерн.
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 становятся простыми, надежными и элегантными:
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 утка).
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: Работа с массивами разных типов
Допустим, у нас есть массив, который может содержать либо числа, либо строки и мы хотим отфильтровать только числа.
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: Проверка на определенный класс (если мы их используем)
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: «Валидатор формы»
У нас есть данные формы, которые могут быть либо в состоянии «успешно отправлены», либо в состоянии «ошибка валидации».
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:
// 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 может измениться), но у тебя есть ожидаемый тип.
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:
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. Давай резюмируем главное:
-
Мы научились помогать TypeScript сужать типы в сложных случаях, особенно когда работаем с нашими кастомными типами (интерфейсами, типами-объединениями).
-
Как это работает. Мы создаем функцию, которая возвращает не просто
boolean, а тип-предикат видаparameterName is SpecificType. -
Власть = ответственность: TypeScript безоговорочно верит нашему guard’у. Если наша логика проверки ошибочна, это приведет к ошибкам во время выполнения. Поэтому guards должны быть максимально надежными.
-
Использование дискриминантных свойств (общих свойств с литеральными типами), это лучший и самый надежный способ различения типов в union’e.
-
Guards незаменимы для валидации данных извне (ответы API, ввод пользователя), для чистоты кода и его переиспользования.
Этот инструмент кардинально меняет то, как ты подходишь к типизации сложных логических потоков в приложении. Он делает твой код не только безопаснее, но и гораздо выразительнее и легче для чтения и поддержки.
В следующих урокпх мы продолжим углубляться в систему типов TypeScript и поговорим о еще более продвинутых техниках.
Полный курс с уроками по TypeScript для начинающих можно найти по адресу: https://max-gabov.ru/typescript-dlya-nachinaushih
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


