У масштабних TypeScript-проєктах зі складною бізнес-логікою система типів перестає бути просто пасивним шаром анотацій і перетворюється на повноцінну обчислювальну систему. Розробка узагальнених утиліт (Generics), парсерів схем валідації (Zod/TypeBox) та типобезпечних роутерів вимагає таких самих гарантій якості, як і прикладний код.
Класичні модульні тести (Jest, Vitest) перевіряють виключно поведінку в рантаймі: вони виконуються над скомпільованим JavaScript-кодом, з якого всі типи повністю видалені (Type Erasure). Якщо в результаті рефакторингу тип функції випадково деградує до any або втратить звуження (Narrowing), звичайні юніт-тести залишаться зеленими, проте користувачі бібліотеки отримають помилки типів або втрату автодоповнення в IDE.
Дисципліна тестування типів (Type Testing) робить систему типів перевірюваною та стійкою до регресій.
Паралельно з тестуванням типів для метапрограмування та генерації коду в enterprise-архітектурі застосовується TypeScript Compiler API та механізм користувацьких AST-трансформерів (AST Transformers).
Архітектура компілятора TypeScript є багатостадійним конвеєром трансформації вихідного тексту програми:
[ Вихідний код TypeScript (*.ts) ]
│
▼
1. SCANNER (Лексичний аналіз: потік токенів SyntaxKind)
│
▼
2. PARSER (Синтаксичний аналіз: побудова SourceFile AST)
│
▼
3. BINDER (Зв'язування ідентифікаторів у таблицю Symbols)
│
▼
4. TYPE CHECKER (Семантичний аналіз типів: getTypeOfSymbolAtLocation)
│
├───► [ TYPE TESTING SUITE (Vitest / tsd) ]
│ (Верифікація еквівалентності типів на рівні TypeChecker)
▼
5. EMITTER (Трансляція AST у JS)
│
├──► [ КАСТОМНІ AST-ТРАНСФОРМЕРИ ] (Мутація дерева вузлів)
│
▼
[ Фінальний артефакт: JavaScript (*.js) + Декларації (*.d.ts) ]z.infer<typeof schema> повертає строго типізований DTO без появи ненавмисних any..where('age', '>', 18) забороняють передавання рядків у числові стовпці на етапі компіляції.Equal<A, B>#Наївна спроба перевірити рівність типів через взаємне присвоєння (A extends B ? (B extends A ? true : false) : false) дає хибнопозитивний результат за наявності об'єднань (Unions) або типу any.
Канонічна реалізація точної перевірки ідентичності типів спирається на контраваріантність параметрів функцій вищого порядку:
// Еталонна реалізація перевірки ідентичності типів (Strict Type Equality)
export type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
export type Expect<T extends true> = T;
export type ExpectFalse<T extends false> = T;
// Приклади перевірки
type Case1 = Expect<Equal<string, string>>; // true
type Case2 = Expect<Equal<number | string, string | number>>; // true
// ❌ Помилка компіляції: Type 'false' does not satisfy the constraint 'true'
// type Case3 = Expect<Equal<any, string>>;Дві функції ідентичні за типами тоді й тільки тоді, коли їхні generic-обмеження обчислюються компілятором абсолютно однаково. Це єдиний спосіб надійно відрізнити any від будь-якого іншого типу.
Сучасний стандарт тестування типів — використання вбудованого модуля expectTypeOf у Vitest:
import { describe, it, expectTypeOf, assertType } from 'vitest';
interface User {
id: string;
name: string;
role: 'admin' | 'viewer';
permissions: string[];
}
// Тестована утиліта глибокого перетворення
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends (infer R)[]
? readonly DeepReadonly<R>[]
: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};
function makeReadonly<T extends object>(target: T): DeepReadonly<T> {
return Object.freeze({ ...target }) as DeepReadonly<T>;
}
describe('Type Testing: DeepReadonly<T>', () => {
it('коректно трансформує всі поля та вкладені масиви у readonly', () => {
const rawUser: User = {
id: 'usr_1',
name: 'Костянтин',
role: 'admin',
permissions: ['read', 'write'],
};
const readonlyUser = makeReadonly(rawUser);
// 1. Перевірка точного типу поверненого значення
expectTypeOf(readonlyUser).toEqualTypeOf<{
readonly id: string;
readonly name: string;
readonly role: 'admin' | 'viewer';
readonly permissions: readonly string[];
}>();
// 2. Перевірка, що властивості неможливо мутувати
// @ts-expect-error Перевіряємо, що компілятор забороняє перезапис поля
readonlyUser.name = 'Нове ім\'я';
// @ts-expect-error Перевіряємо, що push у масив заборонений на рівні типів
readonlyUser.permissions.push('delete');
// 3. Перевірка відповідності сигнатур функцій
assertType<(target: User) => DeepReadonly<User>>(makeReadonly);
});
});Запуск перевірки типів у CI/CD:
pnpm exec vitest --typecheck --runКомпілятор TypeScript дозволяє підключати фабрики трансформації AST-дерева (ts.TransformerFactory). Нижче наведено production-трансформер, який перехоплює виклики функцій та автоматично додає метадані виклику (ім'я файлу та рядок виклику):
import ts from 'typescript';
export function createTraceTransformer(): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => {
const { factory } = context;
return (sourceFile: ts.SourceFile) => {
// Рекурсивний відвідувач вузлів AST
function visitor(node: ts.Node): ts.Node {
// Шукаємо виклики функції monitorScope(...)
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'monitorScope'
) {
// Отримуємо координати рядка у вихідному файлі
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
const locationString = `${sourceFile.fileName}:${line + 1}:${character + 1}`;
// Додаємо рядковий літерал із координатами другим аргументом
const updatedArguments = [
...node.arguments,
factory.createStringLiteral(locationString),
];
return factory.updateCallExpression(
node,
node.expression,
node.typeArguments,
updatedArguments
);
}
// Продовжуємо обхід дочірніх вузлів
return ts.visitEachChild(node, visitor, context);
}
return ts.visitNode(sourceFile, visitor) as ts.SourceFile;
};
};
}Перетворення вихідного тексту на ієрархічне дерево вузлів AST (токени SyntaxKind).
Виконання assertType та expectTypeOf у віртуальному оточенні компілятора.
Кастомний трансформер перехоплює цільові вузли (CallExpression, PropertyAccessExpression).
Генерація фінального валідного JS-коду з інжектованими метаданими та картами коду.
Нижче наведено production-артефакт, що демонструє програмне створення екземпляра програми TypeScript (ts.createProgram) та запуск компіляції з кастомним AST-трансформером:
import ts from 'typescript';
import path from 'node:path';
import { createTraceTransformer } from './traceTransformer';
export interface CompileResult {
success: boolean;
diagnostics: string[];
emittedFiles: string[];
}
export function compileWithCustomTransformers(
rootFilePaths: string[],
compilerOptions: ts.CompilerOptions
): CompileResult {
const host = ts.createCompilerHost(compilerOptions);
const emittedFiles: string[] = [];
// Перехоплюємо запис файлів для збору списку згенерованих артефактів
host.writeFile = (fileName, text, writeByteOrderMark, onError, shouldHandle) => {
emittedFiles.push(fileName);
ts.sys.writeFile(fileName, text, writeByteOrderMark);
};
const program = ts.createProgram(rootFilePaths, compilerOptions, host);
// Підключаємо наш кастомний трансформер у фазу before (до генерації JS)
const customTransformers: ts.CustomTransformers = {
before: [createTraceTransformer()],
};
const emitResult = program.emit(
undefined,
undefined,
undefined,
undefined,
customTransformers
);
const allDiagnostics = ts
.getPreEmitDiagnostics(program)
.concat(emitResult.diagnostics);
const formattedDiagnostics: string[] = allDiagnostics.map((diagnostic) => {
if (diagnostic.file && diagnostic.start !== undefined) {
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(
diagnostic.start
);
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
}
return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
});
return {
success: !emitResult.emitSkipped && formattedDiagnostics.length === 0,
diagnostics: formattedDiagnostics,
emittedFiles,
};
}Директива @ts-ignore сліпо пригнічує будь-яку помилку. Якщо в результаті рефакторингу рядок перестане викликати помилку типів (наприклад, баг усунено або контракт послаблено), @ts-ignore промовчить. Директива @ts-expect-error впаде з помилкою, якщо на наступному рядку помилки НЕМАЄ, гарантуючи актуальність тесту негативного сценарію.
// ❌ Антипатерн: приховає регресію, якщо код раптово стане валідним
// @ts-ignore
const invalidAssignment: number = "string";
// ✅ Правильне рішення: впаде з помилкою 'Unused @ts-expect-error directive',
// якщо компілятор не зафіксує помилку
// @ts-expect-error Очікується помилка несумісності типів
const strictAssignment: number = "string";Спроба тестувати TypeScript-типи через expect(typeof result).toBe('string') у стандартному тесті перевіряє лише значення JavaScript у пам'яті, але нічого не говорить про статичний тип. Змінна може мати тип any у TypeScript, але містити рядок "hello" у рантаймі.
Рішення: використовувати expectTypeOf(result).toBeString() з vitest.
Спроба змінити властивості вузла напряму (node.arguments = [...]) порушує внутрішню цілісність кешів компілятора та хешів ідентичності.
Рішення: вузли AST у TypeScript є імутабельними. Для будь-яких модифікацій необхідно використовувати фабрику context.factory.update* або context.factory.create*.
Додати команду 'test:types': 'vitest --typecheck --run' у package.json проєкту.
Розмістити файли специфікацій типів поруч із вихідними модулями бібліотеки.
Використовувати expectTypeOf().toEqualTypeOf<Expected>() для перевірки точного збігу сигнатур.
Покрити заборонені операції за допомогою директиви // @ts-expect-error із поясненням очікуваної помилки.
Включити крок test:types в обов'язкові перевірки перед злиттям Pull Request.