Одной из фундаментальных проблем интеграции TypeScript с реальным миром является граница исполнения (Runtime Boundary). Данные, поступающие извне — HTTP-запросы, ответы сторонних API, сообщения из WebSocket, записи из базы данных или параметры адресной строки — приходят в приложение с типом unknown или any.
Для безопасного перевода непроверенных структур в строго типизированные доменные модели TypeScript предоставляет два механизма валидации и сужения типов на уровне потока управления (Control Flow Analysis):
val is Type, возвращающие логическое значение boolean для использования в условных конструкциях if.asserts condition или asserts val is Type, которые выбрасывают исключение в случае невыполнения инварианта и бесшовно сужают тип переменной в текущей области видимости без создания лишних уровней вложенности.+-------------------------------------------------------------------------+
| МЕХАНИЗМЫ СУЖЕНИЯ НЕПРОВЕРЕННЫХ ДАННЫХ |
+-------------------------------------------------------------------------+
| [Вход: rawPayload: unknown] |
+-------------------------------------------------------------------------+
|
+------------------------------------+
| |
v (Type Guard: Ветвление) v (Assertion Function: Инвариант)
+---------------------------------+ +------------------------------------+
| if (isUser(rawPayload)) { | | assertIsUser(rawPayload); |
| // rawPayload сужен до User | | // rawPayload сужен до User прямо |
| } else { | | // в текущей области видимости! |
| // обработка ошибки | | |
| } | | (При ошибке — прерывание throw) |
+---------------------------------+ +------------------------------------+При работе с защитниками типов необходимо осознавать архитектурную ответственность:
Механика сужения через asserts:
function process(input: unknown) {
// input имеет тип: unknown
assertIsNonNullObject(input);
// input сужен до: Record<string, unknown>
assertHasProperty(input, 'id');
// input сужен до: Record<string, unknown> & { id: unknown }
assertIsString(input.id);
// input.id сужен до: string!
console.log(input.id.toUpperCase()); // Абсолютно безопасно!
}Никогда не используйте any на границе ввода. Типизируйте сырой payload контроллеров как unknown.
Напишите базовые функции assertIsObject, assertDefined, assertIsString с синтаксисом asserts.
Скомпонуйте проверку всех доменных полей интерфейса внутри assertIsEntity(val: unknown): asserts val is Entity.
Вызовите assertion-функцию в первой строке обработчика. Весь последующий код выполняется над чистым доменным типом.
Протестируйте поведение на null, массивах, объектах с отсутствующими ключами и неверными типами полей.
В чем заключается ключевое отличие сигнатуры asserts val is T от предиката val is T с точки зрения управления потоком исполнения (Control Flow Analysis)?
null и undefined в Array.prototype.filter без потери типов компилятора.process.env на старте микросервиса.useStrictContext, выбрасывающих исключение при отсутствии провайдера в дереве.Создадим набор переиспользуемых функций утверждения для проверки базовых структур:
/**
* Утверждает истинность произвольного логического условия.
*/
export function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message ?? '[InvariantViolation] Условие утверждения ложно');
}
}
/**
* Утверждает, что значение определено и не является null или undefined.
*/
export function assertDefined<T>(
val: T,
message?: string
): asserts val is NonNullable<T> {
if (val === null || val === undefined) {
throw new TypeError(
message ?? `[InvariantViolation] Ожидалось определенное значение, получено: ${String(val)}`
);
}
}
/**
* Утверждает, что значение является объектом без null.
*/
export function assertIsObject(
val: unknown,
message?: string
): asserts val is Record<string, unknown> {
if (typeof val !== 'object' || val === null || Array.isArray(val)) {
throw new TypeError(
message ?? `[InvariantViolation] Ожидался объект, получено: ${typeof val}`
);
}
}Объединим предикаты и assertion-функции для валидации сложного профиля пользователя:
import { assert, assertIsObject, assertDefined } from '../utils/assertions';
export interface UserProfile {
readonly id: string;
readonly email: string;
readonly role: 'admin' | 'editor' | 'viewer';
readonly age?: number;
}
/**
* Пользовательский предикат типа (Type Guard)
*/
export function isUserProfile(val: unknown): val is UserProfile {
try {
assertIsUserProfile(val);
return true;
} catch {
return false;
}
}
/**
* Функция утверждения сложного контракта (Assertion Function)
*/
export function assertIsUserProfile(val: unknown): asserts val is UserProfile {
assertIsObject(val, 'Профиль пользователя должен быть непустым объектом');
assert(
typeof val.id === 'string' && val.id.trim().length > 0,
'Идентификатор пользователя id обязан быть непустой строкой'
);
assert(
typeof val.email === 'string' && val.email.includes('@'),
'Email имеет некорректный формат'
);
assert(
val.role === 'admin' || val.role === 'editor' || val.role === 'viewer',
`Недопустимая роль: ${String(val.role)}`
);
if ('age' in val && val.age !== undefined) {
assert(
typeof val.age === 'number' && Number.isInteger(val.age) && val.age >= 0,
'Возраст age обязан быть неотрицательным целым числом'
);
}
}
// Пример использования в обработчике контроллера:
export function handleUpdateUser(rawBody: unknown): void {
// До утверждения rawBody имеет тип unknown
assertIsUserProfile(rawBody);
// Прямо здесь rawBody СИНХРОННО сужен до UserProfile!
console.log(`Обновление профиля: ${rawBody.id}, роль: ${rawBody.role.toUpperCase()}`);
}Стандартная сигнатура Array.prototype.filter(Boolean) в TypeScript возвращает исходный массив без изменения типа, сохраняя null и undefined. Причина — метод Boolean не типизирован как предикат.
Для корректной фильтрации создается предикатная функция-фильтр:
/**
* Типобезопасный предикат для исключения null и undefined из массивов.
*/
export function isNonNullable<T>(val: T): val is NonNullable<T> {
return val !== null && val !== undefined;
}
// Пример работы:
const rawList: ReadonlyArray<string | null | undefined> = ['alpha', null, 'beta', undefined];
// ❌ ПРОБЛЕМА: filter(Boolean) оставляет тип (string | null | undefined)[]
const badFiltered = rawList.filter(Boolean);
// ✅ РЕШЕНИЕ: filter(isNonNullable) возвращает чистый string[]
const safeList: ReadonlyArray<string> = rawList.filter(isNonNullable);Получение данных из внешней среды (HTTP-тело, JSON.parse, process.env) с наивысшей степенью неопределенности.
Выполнение императивных проверок в JS рантайме. При обнаружении несоответствия выполнение немедленно прерывается выбросом TypeError.
Компилятор видит сигнатуру asserts и модифицирует таблицу типов для переменной в текущем лексическом контексте (Scope).
Последующий код обращается к полям объекта без ручных кастов `as`, проверок на undefined и с полным автодополнением IDE.
import { assert, assertDefined } from '../utils/assertions';
export interface AppConfig {
readonly nodeEnv: 'development' | 'production' | 'test';
readonly port: number;
readonly databaseUrl: string;
readonly enableMetrics: boolean;
}
export function assertValidPort(val: unknown): asserts val is number {
assert(
typeof val === 'number' && Number.isInteger(val) && val > 0 && val <= 65535,
`[ConfigError] Невалидный номер сетевого порта: ${String(val)}`
);
}
export function assertValidDatabaseUrl(val: unknown): asserts val is string {
assert(
typeof val === 'string' &&
(val.startsWith('postgres://') || val.startsWith('postgresql://')),
`[ConfigError] DATABASE_URL обязан быть валидной строкой подключения PostgreSQL`
);
}
/**
* Валидирует глобальное окружение приложения при холодном старте микросервиса.
*/
export function loadAndValidateConfig(env: Record<string, string | undefined>): AppConfig {
assertDefined(env.NODE_ENV, 'Переменная окружения NODE_ENV обязательна');
assert(
env.NODE_ENV === 'development' ||
env.NODE_ENV === 'production' ||
env.NODE_ENV === 'test',
`Недопустимое значение NODE_ENV: ${env.NODE_ENV}`
);
const rawPort = env.PORT ? parseInt(env.PORT, 10) : 3000;
assertValidPort(rawPort);
assertDefined(env.DATABASE_URL, 'DATABASE_URL обязан быть задан в окружении');
assertValidDatabaseUrl(env.DATABASE_URL);
const enableMetrics = env.ENABLE_METRICS === 'true';
return {
nodeEnv: env.NODE_ENV,
port: rawPort,
databaseUrl: env.DATABASE_URL,
enableMetrics,
};
}Какую опасность несет пользовательский предикат или функция утверждения, если компилятор сообщает об успешной проверке типов без ошибок?
Компилятор TypeScript безоговорочно верит возвращаемому типу val is TargetType. Если в теле функции проверить только одно свойство из десяти, TypeScript будет считать объект полностью валидным, что приведет к падению в рантайме при обращении к непроверенным полям.
// ❌ АНТИПАТТЕРН: Дырявый предикат!
interface Order { id: string; amount: number; items: string[]; }
function isOrderBad(val: any): val is Order {
return typeof val?.id === 'string'; // items и amount НЕ проверены!
}
const obj = { id: 'ord_1' };
if (isOrderBad(obj)) {
// Компилятор молчит, но в рантайме произойдет: Cannot read properties of undefined (reading 'map')
obj.items.map(x => x);
}
// ✅ ПРАВИЛЬНО: Исчерпывающая верификация всех обязательных свойствЕсли объявить функцию как function check(val: unknown): val is string, но внутри функции не возвращать boolean, а выбрасывать throw, компилятор не сможет сузить тип переменной в последующих строках кода.
// ❌ АНТИПАТТЕРН: Предикат вместо asserts
function assertBad(x: unknown): x is string {
if (typeof x !== 'string') throw new Error();
return true;
}
assertBad(data);
// data ВСЕ ЕЩЕ ИМЕЕТ ТИП unknown! Предикат работает только внутри if (assertBad(data))!
// ✅ ПРАВИЛЬНО: Использование синтаксиса asserts
function assertGood(x: unknown): asserts x is string {
if (typeof x !== 'string') throw new Error();
}В стрелочных функциях TypeScript требует явного указания : asserts condition перед знаком =, иначе сигнатура утверждения не распознается компилятором.
// ❌ АНТИПАТТЕРН: TypeScript считает, что функция возвращает void
const check = (condition: boolean) => {
if (!condition) throw new Error();
};
// ✅ ПРАВИЛЬНО: Явная декларация сигнатуры утверждения
const checkGood: (condition: boolean) => asserts condition = (condition) => {
if (!condition) throw new Error();
};Оборачивание assertion-функции в пустой блок try/catch нейтрализует проверку инварианта: поток выполнения продолжается над невалидными данными, разрушая целостность системы.
// ❌ АНТИПАТТЕРН: Подавление исключения проверки
try {
assertIsUser(payload);
} catch {
// Пустой catch!
}
// payload не стал безопасным объектом!