Однією з фундаментальних проблем інтеграції TypeScript із реальним світом є границя виконання (Runtime Boundary). Зовнішні дані — тіла HTTP-запитів, відповіді сторонніх API, повідомлення WebSocket, записи з бази даних чи параметри адреси — надходять у застосунок із типом unknown або any.
Для безпечного перетворення неперевірених структур у строго типізовані доменні моделі TypeScript надає два взаємодоповнюючі механізми валідації та звуження типів на рівні аналізу потоку керування (Control Flow Analysis, CFA):
val is Type, що повертають логічне значення boolean для використання в умовних конструкціях if / else.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()); // Абсолютно безпечно!
}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 типізований як повертаючий звичайний примітивний 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 і модифікує таблицю символів для цільової змінної в поточному лексичному скоупі.
Подальший код звертається до властивостей без явних приведень типу (`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, компілятор не зможе звузити тип змінної в наступних рядках коду поза блоком if.
// ❌ АНТИПАТЕРН: Предикат замість 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 перед оператором присвоєння =; інакше компілятор не зможе правильно розпарсити сигнатуру твердження та виведе тип void.
// ❌ АНТИПАТЕРН: Компілятор вважає, що функція повертає 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 НЕ є гарантовано валідним User!Ніколи не використовуйте any на границях введення. Завжди типізуйте сирий payload контролерів як unknown.
Напишіть базові функції assertIsObject, assertDefined, assertIsString із сигнатурою asserts.
Скомпонуйте перевірку всіх доменних полей всередині assertIsEntity(val: unknown): asserts val is Entity.
Викличте функцію твердження в першому рядку обробника. Увесь подальший код працює з чистим доменним типом.
Напишіть тести для перевірки реакції на null, масиви, відсутні поля та невірні типи даних.