В масштабных TypeScript-проектах со сложной бизнес-логикой система типов перестает быть просто пассивным слоем аннотаций и превращается в самостоятельную вычислительную систему. Разработка обобщенных утилит (Generics), парсеров схем валидации (Zod/TypeBox) и типобезопасных роутеров требует такой же гарантии качества, как и прикладной код.
Классические модульные тесты (Jest, Vitest) проверяют исключительно рантайм-поведение: они выполняются над скомпилированным JavaScript-кодом, из которого все типы полностью удалены. Если в результате рефакторинга тип функции случайно деградирует до 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) ]Добавить команду 'test:types': 'vitest --typecheck --run' в package.json проекта.
Разместить файлы спецификаций типов рядом с исходными модулями библиотеки.
Использовать expectTypeOf().toEqualTypeOf<Expected>() для проверки точного совпадения сигнатур.
Покрыть запрещенные операции с помощью директивы // @ts-expect-error с пояснением ожидаемой ошибки.
Включить шаг test:types в обязательные проверки перед слиянием Pull Request.
Почему наивное сравнение типов (A extends B ? (B extends A ? true : false) : false) дает ложноположительный результат и как работает эталонный Equal<X, Y>?
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-expect-error гарантирует регрессионную надежность?
Директива @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') в стандартном Vitest тесте проверяет значение JavaScript в памяти, но ничего не говорит о статическом типе. Переменная может иметь тип any в TypeScript, но значение "hello" в рантайме.
Решение: использовать expectTypeOf(result).toBeString() из vitest.
Попытка изменить свойства узла напрямую (node.arguments = [...]) приводит к нарушению внутренней целостности кэшей компилятора.
Решение: узлы AST в TypeScript иммутабельны. Для любых модификаций необходимо использовать методы фабрики context.factory.update* или context.factory.create*.