En proyectos TypeScript de gran envergadura con lógica de negocio sofisticada, el sistema de tipos deja de ser una capa pasiva de anotaciones y se transforma en un motor de computación por derecho propio. El desarrollo de utilidades genéricas, analizadores de esquemas (Zod, TypeBox) y enrutadores con seguridad de tipos de extremo a extremo exige las mismas garantías de calidad automatizadas que el código de ejecución en runtime.
Las suites tradicionales de pruebas unitarias (Jest, Vitest) evalúan exclusivamente el comportamiento en tiempo de ejecución: se ejecutan sobre el código JavaScript compilado, del cual todos los tipos han sido eliminados por completo (Type Erasure). Si una refactorización hace que la firma de una función degrade inadvertidamente a any o pierda precisión de estrechamiento (Narrowing), los tests unitarios convencionales permanecerán en verde, pero los consumidores de la librería sufrirán errores de compilación o la pérdida del autocompletado en el IDE.
La disciplina de Type Testing (Pruebas de Tipos) convierte el sistema de tipos en un artefacto verificable y resistente a regresiones.
Paralelamente, para tareas de metaprogramación y generación de código en arquitecturas enterprise se recurre a la TypeScript Compiler API y al mecanismo de Transformadores AST (AST Transformers).
El compilador de TypeScript funciona como un pipeline multietapa que transforma el código fuente en JavaScript verificado y archivos de declaración:
[ Código Fuente TypeScript (*.ts) ]
│
▼
1. SCANNER (Análisis Léxico: flujo de tokens SyntaxKind)
│
▼
2. PARSER (Análisis Sintáctico: construcción del AST SourceFile)
│
▼
3. BINDER (Resolución de Símbolos: población de Tablas de Símbolos)
│
▼
4. TYPE CHECKER (Análisis Semántico: getTypeOfSymbolAtLocation)
│
├───► [ SUITE DE TYPE TESTING (Vitest / tsd) ]
│ (Verificación de equivalencia a nivel de TypeChecker)
▼
5. EMITTER (Emisión del AST a JS)
│
├──► [ TRANSFORMADORES AST PERSONALIZADOS ] (Mutación de nodos)
│
▼
[ Artefactos Finales: JavaScript (*.js) + Declaraciones (*.d.ts) ]z.infer<typeof schema> devuelve DTOs estrictamente tipados sin fugas accidentales de any..where('age', '>', 18) rechazan strings en columnas numéricas durante la compilación.Equal<A, B>#Un intento ingenuo de validar la igualdad de tipos mediante asignación mutua (A extends B ? (B extends A ? true : false) : false) genera falsos positivos ante tipos unión o el tipo permisivo any.
La implementación canónica de igualdad exacta de tipos se fundamenta en la contravariancia de los parámetros de funciones de orden superior:
// Implementación canónica de igualdad estricta de tipos (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;
// Casos de prueba
type Case1 = Expect<Equal<string, string>>; // true
type Case2 = Expect<Equal<number | string, string | number>>; // true
// ❌ Error de compilación: Type 'false' does not satisfy the constraint 'true'
// type Case3 = Expect<Equal<any, string>>;Dos funciones genéricas de orden superior son idénticas si y solo si sus restricciones genéricas se evalúan de forma idéntica en el compilador. Este es el único mecanismo fiable para distinguir any de cualquier otro tipo.
El estándar corporativo moderno para testing de tipos es el módulo nativo expectTypeOf en Vitest:
import { describe, it, expectTypeOf, assertType } from 'vitest';
interface User {
id: string;
name: string;
role: 'admin' | 'viewer';
permissions: string[];
}
// Utilidad de transformación profunda en prueba
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('transforma recursivamente propiedades y arrays anidados en estructuras de solo lectura', () => {
const rawUser: User = {
id: 'usr_1',
name: 'Konstantin',
role: 'admin',
permissions: ['read', 'write'],
};
const readonlyUser = makeReadonly(rawUser);
// 1. Verificar el tipo exacto del valor retornado
expectTypeOf(readonlyUser).toEqualTypeOf<{
readonly id: string;
readonly name: string;
readonly role: 'admin' | 'viewer';
readonly permissions: readonly string[];
}>();
// 2. Verificar que las propiedades no se pueden mutar
// @ts-expect-error Valida que el compilador rechace la reasignación de campos
readonlyUser.name = 'Nuevo Nombre';
// @ts-expect-error Valida que métodos mutadores de arrays estén prohibidos
readonlyUser.permissions.push('delete');
// 3. Verificar compatibilidad de firmas de función
assertType<(target: User) => DeepReadonly<User>>(makeReadonly);
});
});Ejecución de pruebas de tipos en CI/CD:
pnpm exec vitest --typecheck --runTypeScript permite registrar fábricas de transformación del árbol AST (ts.TransformerFactory). A continuación se presenta un transformador para entornos de producción que intercepta llamadas a funciones e inyecta metadatos del callsite (archivo y número de línea) de manera automática:
import ts from 'typescript';
export function createTraceTransformer(): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => {
const { factory } = context;
return (sourceFile: ts.SourceFile) => {
// Visitante recursivo de nodos AST
function visitor(node: ts.Node): ts.Node {
// Interceptar llamadas a la función monitorScope(...)
if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'monitorScope'
) {
// Extraer coordenadas de línea y carácter en el archivo fuente
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
const locationString = `${sourceFile.fileName}:${line + 1}:${character + 1}`;
// Adjuntar la cadena de ubicación como segundo argumento inyectado
const updatedArguments = [
...node.arguments,
factory.createStringLiteral(locationString),
];
return factory.updateCallExpression(
node,
node.expression,
node.typeArguments,
updatedArguments
);
}
// Continuar recorriendo nodos descendientes
return ts.visitEachChild(node, visitor, context);
}
return ts.visitNode(sourceFile, visitor) as ts.SourceFile;
};
};
}Conversión de texto plano en estructura jerárquica de nodos AST (tokens SyntaxKind).
Evaluación de aserciones assertType y expectTypeOf en el contexto virtual del compilador.
El transformador intercepta nodos diana (CallExpression, PropertyAccessExpression).
Generación de código JavaScript final con metadatos inyectados y sourcemaps.
A continuación se muestra un ejecutor de TypeScript que ilustra cómo instanciar un programa de forma programática (ts.createProgram) e integrar transformadores AST personalizados:
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[] = [];
// Interceptar la escritura de archivos para recolectar artefactos generados
host.writeFile = (fileName, text, writeByteOrderMark, onError, shouldHandle) => {
emittedFiles.push(fileName);
ts.sys.writeFile(fileName, text, writeByteOrderMark);
};
const program = ts.createProgram(rootFilePaths, compilerOptions, host);
// Vincular el transformador a la fase before (previa a la emisión de 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 suprime cualquier error de forma incondicional. Si una refactorización subsana el fallo o relaja el contrato y la línea pasa a ser válida, @ts-ignore permanece en silencio. Por el contrario, @ts-expect-error provoca un fallo de compilación si la línea subsiguiente NO arroja error, garantizando la vigencia de los casos de prueba negativos.
// ❌ Antipatrón: oculta regresiones si el código se vuelve válido inesperadamente
// @ts-ignore
const invalidAssignment: number = "string";
// ✅ Práctica recomendada: falla con 'Unused @ts-expect-error directive'
// si el compilador no detecta error
// @ts-expect-error Error de incompatibilidad de tipos esperado
const strictAssignment: number = "string";Verificar tipos mediante expect(typeof result).toBe('string') en un test convencional solo examina el valor en memoria de JavaScript, sin validar el tipo estático. Una variable puede tiparse como any en TypeScript y albergar "hello" en ejecución.
Solución: utilizar expectTypeOf(result).toBeString() provisto por vitest.
Mutar propiedades de un nodo de forma directa (node.arguments = [...]) destruye las cachés internas del compilador y corrompe los hashes de identidad de nodos.
Solución: los nodos del AST son inmutables. Se deben invocar siempre los métodos de fábrica (context.factory.update* o context.factory.create*).
Incorporar el comando 'test:types': 'vitest --typecheck --run' en los scripts del proyecto.
Ubicar las suites de verificación de tipos contiguas a los módulos fuente de la librería.
Emplear expectTypeOf().toEqualTypeOf<Expected>() para constatar la coincidencia exacta de firmas.
Asegurar que operaciones prohibidas fallen mediante // @ts-expect-error con mensaje descriptivo.
Integrar el paso test:types como validación obligatoria antes de autorizar el merge de Pull Requests.