@elliots/typical 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,9 @@
1
1
  import ts from "typescript";
2
+ import fs from "fs";
3
+ import path from "path";
2
4
  import { loadConfig, TypicalConfig } from "./config.js";
3
5
  import { shouldTransformFile } from "./file-filter.js";
6
+ import { hoistRegexConstructors } from "./regex-hoister.js";
4
7
 
5
8
  import { transform as typiaTransform } from "typia/lib/transform.js";
6
9
  import { setupTsProgram } from "./setup.js";
@@ -89,6 +92,10 @@ export class TypicalTransformer {
89
92
  };
90
93
 
91
94
  return (sourceFile: ts.SourceFile) => {
95
+ // Check if this file should be transformed based on include/exclude patterns
96
+ if (!this.shouldTransformFile(sourceFile.fileName)) {
97
+ return sourceFile; // Return unchanged for excluded files
98
+ }
92
99
 
93
100
  if (process.env.DEBUG) {
94
101
  console.log("TYPICAL: processing ", sourceFile.fileName);
@@ -112,6 +119,28 @@ export class TypicalTransformer {
112
119
  console.log("TYPICAL: Before typia transform (first 500 chars):", transformedCode.substring(0, 500));
113
120
  }
114
121
 
122
+ // Write intermediate file if debug option is enabled
123
+ if (this.config.debug?.writeIntermediateFiles) {
124
+ const compilerOptions = this.program.getCompilerOptions();
125
+ const outDir = compilerOptions.outDir || ".";
126
+ const rootDir = compilerOptions.rootDir || ".";
127
+
128
+ // Calculate the relative path from rootDir
129
+ const relativePath = path.relative(rootDir, sourceFile.fileName);
130
+ // Change extension to .typical.ts(x) to indicate intermediate state, preserving tsx
131
+ const intermediateFileName = relativePath.replace(/\.(tsx?)$/, ".typical.$1");
132
+ const intermediateFilePath = path.join(outDir, intermediateFileName);
133
+
134
+ // Ensure directory exists
135
+ const dir = path.dirname(intermediateFilePath);
136
+ if (!fs.existsSync(dir)) {
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ }
139
+
140
+ fs.writeFileSync(intermediateFilePath, transformedCode);
141
+ console.log(`TYPICAL: Wrote intermediate file: ${intermediateFilePath}`);
142
+ }
143
+
115
144
  if (transformedCode.includes("typia.")) {
116
145
  try {
117
146
  // Create a new source file from our transformed code
@@ -200,7 +229,10 @@ export class TypicalTransformer {
200
229
  if (stmt.importClause?.isTypeOnly) {
201
230
  continue;
202
231
  }
203
- syntheticStatements.push(this.recreateImportDeclaration(stmt, factory));
232
+ const recreated = this.recreateImportDeclaration(stmt, factory, newProgram.getTypeChecker());
233
+ if (recreated) {
234
+ syntheticStatements.push(recreated);
235
+ }
204
236
  } else {
205
237
  syntheticStatements.push(stmt);
206
238
  }
@@ -216,7 +248,34 @@ export class TypicalTransformer {
216
248
  typiaTransformed.hasNoDefaultLib,
217
249
  typiaTransformed.libReferenceDirectives
218
250
  );
251
+
252
+ // Hoist RegExp constructors to top-level constants for performance
253
+ if (this.config.hoistRegex !== false) {
254
+ transformedSourceFile = hoistRegexConstructors(
255
+ transformedSourceFile,
256
+ this.ts,
257
+ factory
258
+ );
259
+ }
260
+
261
+ // Check for untransformed typia calls - these indicate types typia couldn't process
262
+ const finalCode = printer.printFile(transformedSourceFile);
263
+ const untransformedCalls = this.findUntransformedTypiaCalls(finalCode);
264
+ if (untransformedCalls.length > 0) {
265
+ const failedTypes = untransformedCalls.map(c => c.type).filter((v, i, a) => a.indexOf(v) === i);
266
+ throw new Error(
267
+ `TYPICAL: Failed to transform the following types (typia cannot process them):\n` +
268
+ failedTypes.map(t => ` - ${t}`).join('\n') +
269
+ `\n\nTo skip validation for these types, add to ignoreTypes in typical.json:\n` +
270
+ ` "ignoreTypes": [${failedTypes.map(t => `"${t}"`).join(', ')}]` +
271
+ `\n\nFile: ${sourceFile.fileName}`
272
+ );
273
+ }
219
274
  } catch (error) {
275
+ // Re-throw our own errors, only catch typia transform failures
276
+ if (error instanceof Error && error.message.startsWith('TYPICAL:')) {
277
+ throw error;
278
+ }
220
279
  console.warn("Failed to apply typia transformer:", sourceFile.fileName, error);
221
280
  }
222
281
  }
@@ -233,8 +292,9 @@ export class TypicalTransformer {
233
292
  */
234
293
  private recreateImportDeclaration(
235
294
  importDecl: ts.ImportDeclaration,
236
- factory: ts.NodeFactory
237
- ): ts.ImportDeclaration {
295
+ factory: ts.NodeFactory,
296
+ typeChecker: ts.TypeChecker
297
+ ): ts.ImportDeclaration | undefined {
238
298
  let importClause: ts.ImportClause | undefined;
239
299
 
240
300
  if (importDecl.importClause) {
@@ -249,20 +309,54 @@ export class TypicalTransformer {
249
309
  );
250
310
  } else if (this.ts.isNamedImports(clause.namedBindings)) {
251
311
  // import { foo, bar } from "baz"
252
- const elements = clause.namedBindings.elements.map((el) =>
253
- factory.createImportSpecifier(
254
- el.isTypeOnly,
255
- el.propertyName ? factory.createIdentifier(el.propertyName.text) : undefined,
256
- factory.createIdentifier(el.name.text)
257
- )
258
- );
259
- namedBindings = factory.createNamedImports(elements);
312
+ // Filter out type-only imports (explicit or inferred from symbol)
313
+ const elements = clause.namedBindings.elements
314
+ .filter((el) => {
315
+ // Skip explicit type-only specifiers
316
+ if (el.isTypeOnly) return false;
317
+ // Check if the symbol is type-only (interface, type alias, etc.)
318
+ let symbol = typeChecker.getSymbolAtLocation(el.name);
319
+ // Follow alias to get the actual exported symbol
320
+ if (symbol && symbol.flags & this.ts.SymbolFlags.Alias) {
321
+ symbol = typeChecker.getAliasedSymbol(symbol);
322
+ }
323
+ if (symbol) {
324
+ const declarations = symbol.getDeclarations();
325
+ if (declarations && declarations.length > 0) {
326
+ // If all declarations are type-only, skip this import
327
+ const allTypeOnly = declarations.every((decl) =>
328
+ this.ts.isInterfaceDeclaration(decl) ||
329
+ this.ts.isTypeAliasDeclaration(decl) ||
330
+ this.ts.isTypeLiteralNode(decl)
331
+ );
332
+ if (allTypeOnly) return false;
333
+ }
334
+ }
335
+ return true;
336
+ })
337
+ .map((el) =>
338
+ factory.createImportSpecifier(
339
+ false,
340
+ el.propertyName ? factory.createIdentifier(el.propertyName.text) : undefined,
341
+ factory.createIdentifier(el.name.text)
342
+ )
343
+ );
344
+ // Only create named imports if there are non-type specifiers
345
+ if (elements.length > 0) {
346
+ namedBindings = factory.createNamedImports(elements);
347
+ }
260
348
  }
261
349
  }
262
350
 
351
+ // Skip import entirely if no default import and no named bindings remain
352
+ const defaultName = clause.name ? factory.createIdentifier(clause.name.text) : undefined;
353
+ if (!defaultName && !namedBindings) {
354
+ return undefined;
355
+ }
356
+
263
357
  importClause = factory.createImportClause(
264
358
  clause.isTypeOnly,
265
- clause.name ? factory.createIdentifier(clause.name.text) : undefined,
359
+ defaultName,
266
360
  namedBindings
267
361
  );
268
362
  }
@@ -289,11 +383,6 @@ export class TypicalTransformer {
289
383
  ): ts.SourceFile {
290
384
  const { ts } = ctx;
291
385
 
292
- // Check if this file should be transformed
293
- if (!this.shouldTransformFile(sourceFile.fileName)) {
294
- return sourceFile; // Return unchanged for excluded files
295
- }
296
-
297
386
  if (!sourceFile.fileName.includes('transformer.test.ts')) {
298
387
  // Check if this file has already been transformed by us
299
388
  const sourceText = sourceFile.getFullText();
@@ -328,115 +417,22 @@ export class TypicalTransformer {
328
417
  if (node.arguments.length > 0) {
329
418
  const arg = node.arguments[0];
330
419
  const argType = typeChecker.getTypeAtLocation(arg);
331
- const typeFlags = argType.flags;
332
- // Skip if type is any (1) or unknown (2)
333
- if (typeFlags & ts.TypeFlags.Any || typeFlags & ts.TypeFlags.Unknown) {
420
+ if (this.isAnyOrUnknownTypeFlags(argType)) {
334
421
  return node; // Don't transform JSON.stringify for any/unknown types
335
422
  }
336
423
  }
337
424
 
338
425
  if (this.config.reusableValidators) {
339
- // For JSON.stringify, try to infer the type from the argument
340
- let typeText = "unknown";
341
- let typeNodeForCache: ts.TypeNode | undefined;
342
-
343
- if (node.arguments.length > 0) {
344
- const arg = node.arguments[0];
345
-
346
- // Check if it's a type assertion
347
- if (ts.isAsExpression(arg)) {
348
- // For type assertions, use the asserted type directly
349
- const assertedType = arg.type;
350
- const objectType =
351
- typeChecker.getTypeFromTypeNode(assertedType);
352
-
353
- const typeNode = assertedType;
354
-
355
- if (typeNode) {
356
- const typeString = typeChecker.typeToString(objectType);
357
- typeText = `Asserted_${typeString.replace(
358
- /[^a-zA-Z0-9_]/g,
359
- "_"
360
- )}`;
361
- typeNodeForCache = typeNode;
362
- } else {
363
- typeText = "unknown";
364
- typeNodeForCache = ctx.factory.createKeywordTypeNode(
365
- ctx.ts.SyntaxKind.UnknownKeyword
366
- );
367
- }
368
- } else if (ts.isObjectLiteralExpression(arg)) {
369
- // For object literals, use the type checker to get the actual type
370
- const objectType = typeChecker.getTypeAtLocation(arg);
371
-
372
- const typeNode = typeChecker.typeToTypeNode(
373
- objectType,
374
- arg,
375
- ts.NodeBuilderFlags.InTypeAlias
376
- );
377
-
378
- if (typeNode) {
379
- const propNames = arg.properties
380
- .map((prop) => {
381
- if (ts.isShorthandPropertyAssignment(prop)) {
382
- return prop.name.text;
383
- } else if (
384
- ts.isPropertyAssignment(prop) &&
385
- ts.isIdentifier(prop.name)
386
- ) {
387
- return prop.name.text;
388
- }
389
- return "unknown";
390
- })
391
- .sort()
392
- .join("_");
393
-
394
- typeText = `ObjectLiteral_${propNames}`;
395
- typeNodeForCache = typeNode;
396
- } else {
397
- // typeText = "unknown";
398
- // typeNodeForCache = ctx.factory.createKeywordTypeNode(
399
- // ctx.ts.SyntaxKind.UnknownKeyword
400
- // );
401
- throw new Error('unknown type node for object literal: ' + arg.getText());
402
- }
403
- } else {
404
- // For other expressions, try to get the type from the type checker
405
- const argType = typeChecker.getTypeAtLocation(arg);
406
-
407
- const typeNode = typeChecker.typeToTypeNode(
408
- argType,
409
- arg,
410
- ts.NodeBuilderFlags.InTypeAlias
411
- );
412
- if (typeNode) {
413
- const typeString = typeChecker.typeToString(argType);
414
- typeText = `Expression_${typeString.replace(
415
- /[^a-zA-Z0-9_]/g,
416
- "_"
417
- )}`;
418
- typeNodeForCache = typeNode;
419
- } else {
420
- typeText = "unknown";
421
- typeNodeForCache = ctx.factory.createKeywordTypeNode(
422
- ctx.ts.SyntaxKind.UnknownKeyword
423
- )
424
- }
425
- }
426
- }
427
-
428
- const stringifierName = this.getOrCreateStringifier(
429
- typeText,
430
- typeNodeForCache!
431
- );
426
+ // Infer type from argument
427
+ const arg = node.arguments[0];
428
+ const { typeText, typeNode } = this.inferStringifyType(arg, typeChecker, ctx);
432
429
 
433
- const newCall = ctx.factory.createCallExpression(
430
+ const stringifierName = this.getOrCreateStringifier(typeText, typeNode);
431
+ return ctx.factory.createCallExpression(
434
432
  ctx.factory.createIdentifier(stringifierName),
435
433
  undefined,
436
434
  node.arguments
437
435
  );
438
-
439
- return newCall;
440
436
  } else {
441
437
  // Use inline typia.json.stringify
442
438
  return ctx.factory.updateCallExpression(
@@ -491,13 +487,7 @@ export class TypicalTransformer {
491
487
  parent = parent.parent;
492
488
  }
493
489
 
494
- // Skip transformation if target type is any or unknown
495
- const isAnyOrUnknown = targetType && (
496
- targetType.kind === ts.SyntaxKind.AnyKeyword ||
497
- targetType.kind === ts.SyntaxKind.UnknownKeyword
498
- );
499
-
500
- if (isAnyOrUnknown) {
490
+ if (targetType && this.isAnyOrUnknownType(targetType)) {
501
491
  // Don't transform JSON.parse for any/unknown types
502
492
  return node;
503
493
  }
@@ -509,9 +499,8 @@ export class TypicalTransformer {
509
499
  }
510
500
 
511
501
  if (this.config.reusableValidators && targetType) {
512
- // Use reusable parser - use typeToString
513
- const targetTypeObj = typeChecker.getTypeFromTypeNode(targetType);
514
- const typeText = typeChecker.typeToString(targetTypeObj);
502
+ // Use reusable parser - use typeNode text to preserve local aliases
503
+ const typeText = this.getTypeKey(targetType, typeChecker);
515
504
  const parserName = this.getOrCreateParser(typeText, targetType);
516
505
 
517
506
  const newCall = ctx.factory.createCallExpression(
@@ -544,6 +533,52 @@ export class TypicalTransformer {
544
533
  }
545
534
  }
546
535
 
536
+ // Transform type assertions (as expressions) when validateCasts is enabled
537
+ // e.g., `obj as User` becomes `__typical_assert_N(obj)`
538
+ if (this.config.validateCasts && ts.isAsExpression(node)) {
539
+ const targetType = node.type;
540
+
541
+ // Skip 'as any' and 'as unknown' casts - these are intentional escapes
542
+ if (this.isAnyOrUnknownType(targetType)) {
543
+ return ctx.ts.visitEachChild(node, visit, ctx.context);
544
+ }
545
+
546
+ // Skip primitive types - no runtime validation needed
547
+ if (targetType.kind === ts.SyntaxKind.StringKeyword ||
548
+ targetType.kind === ts.SyntaxKind.NumberKeyword ||
549
+ targetType.kind === ts.SyntaxKind.BooleanKeyword) {
550
+ return ctx.ts.visitEachChild(node, visit, ctx.context);
551
+ }
552
+
553
+ needsTypiaImport = true;
554
+
555
+ // Visit the expression first to transform any nested casts
556
+ const visitedExpression = ctx.ts.visitNode(node.expression, visit) as ts.Expression;
557
+
558
+ if (this.config.reusableValidators) {
559
+ // Use typeNode text to preserve local aliases
560
+ const typeText = this.getTypeKey(targetType, typeChecker);
561
+ const validatorName = this.getOrCreateValidator(typeText, targetType);
562
+
563
+ // Replace `expr as Type` with `__typical_assert_N(expr)`
564
+ return ctx.factory.createCallExpression(
565
+ ctx.factory.createIdentifier(validatorName),
566
+ undefined,
567
+ [visitedExpression]
568
+ );
569
+ } else {
570
+ // Inline validator: typia.assert<Type>(expr)
571
+ return ctx.factory.createCallExpression(
572
+ ctx.factory.createPropertyAccessExpression(
573
+ ctx.factory.createIdentifier("typia"),
574
+ "assert"
575
+ ),
576
+ [targetType],
577
+ [visitedExpression]
578
+ );
579
+ }
580
+ }
581
+
547
582
  // Transform function declarations
548
583
  if (ts.isFunctionDeclaration(node)) {
549
584
  needsTypiaImport = true;
@@ -565,139 +600,6 @@ export class TypicalTransformer {
565
600
  return ctx.ts.visitEachChild(node, visit, ctx.context);
566
601
  };
567
602
 
568
- // Helper functions for flow analysis
569
- const getRootIdentifier = (expr: ts.Expression): string | undefined => {
570
- if (ts.isIdentifier(expr)) {
571
- return expr.text;
572
- }
573
- if (ts.isPropertyAccessExpression(expr)) {
574
- return getRootIdentifier(expr.expression);
575
- }
576
- return undefined;
577
- };
578
-
579
- const containsReference = (expr: ts.Expression, name: string): boolean => {
580
- if (ts.isIdentifier(expr) && expr.text === name) {
581
- return true;
582
- }
583
- if (ts.isPropertyAccessExpression(expr)) {
584
- return containsReference(expr.expression, name);
585
- }
586
- if (ts.isElementAccessExpression(expr)) {
587
- return containsReference(expr.expression, name) ||
588
- containsReference(expr.argumentExpression as ts.Expression, name);
589
- }
590
- // Check all children
591
- let found = false;
592
- ts.forEachChild(expr, (child) => {
593
- if (ts.isExpression(child) && containsReference(child, name)) {
594
- found = true;
595
- }
596
- });
597
- return found;
598
- };
599
-
600
- // Check if a validated variable has been tainted in the function body
601
- const isTainted = (varName: string, body: ts.Block): boolean => {
602
- let tainted = false;
603
-
604
- // First pass: collect aliases (variables that reference properties of varName)
605
- // e.g., const addr = user.address; -> addr is an alias
606
- const aliases = new Set<string>([varName]);
607
-
608
- const collectAliases = (node: ts.Node): void => {
609
- // Look for: const/let x = varName.property or const/let x = varName
610
- if (ts.isVariableStatement(node)) {
611
- for (const decl of node.declarationList.declarations) {
612
- if (ts.isIdentifier(decl.name) && decl.initializer) {
613
- // Check if initializer is rooted at our tracked variable or any existing alias
614
- const initRoot = getRootIdentifier(decl.initializer);
615
- if (initRoot && aliases.has(initRoot)) {
616
- aliases.add(decl.name.text);
617
- }
618
- }
619
- }
620
- }
621
- ts.forEachChild(node, collectAliases);
622
- };
623
- collectAliases(body);
624
-
625
- // Helper to check if any alias is involved
626
- const involvesTrackedVar = (expr: ts.Expression): boolean => {
627
- const root = getRootIdentifier(expr);
628
- return root !== undefined && aliases.has(root);
629
- };
630
-
631
- const checkTainting = (node: ts.Node): void => {
632
- if (tainted) return; // Early exit if already tainted
633
-
634
- // Reassignment: trackedVar = ...
635
- if (ts.isBinaryExpression(node) &&
636
- node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
637
- ts.isIdentifier(node.left) &&
638
- aliases.has(node.left.text)) {
639
- tainted = true;
640
- return;
641
- }
642
-
643
- // Property assignment: trackedVar.x = ... or alias.x = ...
644
- if (ts.isBinaryExpression(node) &&
645
- node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
646
- ts.isPropertyAccessExpression(node.left) &&
647
- involvesTrackedVar(node.left)) {
648
- tainted = true;
649
- return;
650
- }
651
-
652
- // Element assignment: trackedVar[x] = ... or alias[x] = ...
653
- if (ts.isBinaryExpression(node) &&
654
- node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
655
- ts.isElementAccessExpression(node.left) &&
656
- involvesTrackedVar(node.left.expression)) {
657
- tainted = true;
658
- return;
659
- }
660
-
661
- // Passed as argument to a function: fn(trackedVar) or fn(alias)
662
- if (ts.isCallExpression(node)) {
663
- for (const arg of node.arguments) {
664
- // Check if any tracked variable or alias appears in the argument
665
- let hasTrackedRef = false;
666
- const checkRef = (n: ts.Node): void => {
667
- if (ts.isIdentifier(n) && aliases.has(n.text)) {
668
- hasTrackedRef = true;
669
- }
670
- ts.forEachChild(n, checkRef);
671
- };
672
- checkRef(arg);
673
- if (hasTrackedRef) {
674
- tainted = true;
675
- return;
676
- }
677
- }
678
- }
679
-
680
- // Method call on the variable: trackedVar.method() or alias.method()
681
- if (ts.isCallExpression(node) &&
682
- ts.isPropertyAccessExpression(node.expression) &&
683
- involvesTrackedVar(node.expression.expression)) {
684
- tainted = true;
685
- return;
686
- }
687
-
688
- // Await expression (async boundary - external code could run)
689
- if (ts.isAwaitExpression(node)) {
690
- tainted = true;
691
- return;
692
- }
693
-
694
- ts.forEachChild(node, checkTainting);
695
- };
696
-
697
- checkTainting(body);
698
- return tainted;
699
- };
700
-
701
603
  const transformFunction = (
702
604
  func: ts.FunctionDeclaration | ts.ArrowFunction | ts.MethodDeclaration
703
605
  ): ts.Node => {
@@ -732,8 +634,19 @@ export class TypicalTransformer {
732
634
  func.parameters.forEach((param) => {
733
635
  if (param.type) {
734
636
  // Skip 'any' and 'unknown' types - no point validating them
735
- if (param.type.kind === ts.SyntaxKind.AnyKeyword ||
736
- param.type.kind === ts.SyntaxKind.UnknownKeyword) {
637
+ if (this.isAnyOrUnknownType(param.type)) {
638
+ return;
639
+ }
640
+
641
+ // Skip types matching ignoreTypes patterns
642
+ const typeText = this.getTypeKey(param.type, typeChecker);
643
+ if (process.env.DEBUG) {
644
+ console.log(`TYPICAL: Processing parameter type: ${typeText}`);
645
+ }
646
+ if (this.isIgnoredType(typeText)) {
647
+ if (process.env.DEBUG) {
648
+ console.log(`TYPICAL: Skipping ignored type for parameter: ${typeText}`);
649
+ }
737
650
  return;
738
651
  }
739
652
 
@@ -747,8 +660,7 @@ export class TypicalTransformer {
747
660
  validatedVariables.set(paramName, paramType);
748
661
 
749
662
  if (this.config.reusableValidators) {
750
- // Use reusable validators - use typeToString
751
- const typeText = typeChecker.typeToString(paramType);
663
+ // Use reusable validators - use typeNode text to preserve local aliases
752
664
  const validatorName = this.getOrCreateValidator(
753
665
  typeText,
754
666
  param.type
@@ -796,8 +708,7 @@ export class TypicalTransformer {
796
708
  for (const decl of node.declarationList.declarations) {
797
709
  if (decl.type && ts.isIdentifier(decl.name)) {
798
710
  // Skip any/unknown types
799
- if (decl.type.kind !== ts.SyntaxKind.AnyKeyword &&
800
- decl.type.kind !== ts.SyntaxKind.UnknownKeyword) {
711
+ if (!this.isAnyOrUnknownType(decl.type)) {
801
712
  const constType = typeChecker.getTypeFromTypeNode(decl.type);
802
713
  validatedVariables.set(decl.name.text, constType);
803
714
  }
@@ -866,12 +777,15 @@ export class TypicalTransformer {
866
777
  }
867
778
 
868
779
  // Skip 'any' and 'unknown' return types - no point validating them
869
- const isAnyOrUnknownReturn = returnType && (
870
- returnType.kind === ts.SyntaxKind.AnyKeyword ||
871
- returnType.kind === ts.SyntaxKind.UnknownKeyword
872
- );
873
-
874
- if (returnType && returnTypeForString && !isAnyOrUnknownReturn) {
780
+ // Also skip types matching ignoreTypes patterns
781
+ const returnTypeText = returnType && returnTypeForString
782
+ ? this.getTypeKey(returnType, typeChecker, returnTypeForString)
783
+ : null;
784
+ const isIgnoredReturnType = returnTypeText && this.isIgnoredType(returnTypeText);
785
+ if (isIgnoredReturnType && process.env.DEBUG) {
786
+ console.log(`TYPICAL: Skipping ignored type for return: ${returnTypeText}`);
787
+ }
788
+ if (returnType && returnTypeForString && !this.isAnyOrUnknownType(returnType) && !isIgnoredReturnType) {
875
789
  const returnTransformer = (node: ts.Node): ts.Node => {
876
790
  if (ts.isReturnStatement(node) && node.expression) {
877
791
  // Skip return validation if the expression already contains a __typical _parse_* call
@@ -891,10 +805,10 @@ export class TypicalTransformer {
891
805
 
892
806
  // Flow analysis: Skip return validation if returning a validated variable
893
807
  // (or property of one) that hasn't been tainted
894
- const rootVar = getRootIdentifier(node.expression);
808
+ const rootVar = this.getRootIdentifier(node.expression);
895
809
  if (rootVar && validatedVariables.has(rootVar)) {
896
810
  // Check if the variable has been tainted (mutated, passed to function, etc.)
897
- if (!isTainted(rootVar, visitedBody)) {
811
+ if (!this.isTainted(rootVar, visitedBody)) {
898
812
  // Return expression is rooted at a validated, untainted variable
899
813
  // For direct returns (identifier) or property access, we can skip validation
900
814
  if (ts.isIdentifier(node.expression) || ts.isPropertyAccessExpression(node.expression)) {
@@ -918,8 +832,9 @@ export class TypicalTransformer {
918
832
  }
919
833
 
920
834
  if (this.config.reusableValidators) {
921
- // Use reusable validators - always use typeToString
922
- const returnTypeText = typeChecker.typeToString(returnTypeForString!);
835
+ // Use reusable validators - use typeNode text to preserve local aliases
836
+ // Pass returnTypeForString for synthesized nodes (inferred return types)
837
+ const returnTypeText = this.getTypeKey(returnType, typeChecker, returnTypeForString);
923
838
  const validatorName = this.getOrCreateValidator(
924
839
  returnTypeText,
925
840
  returnType
@@ -1045,6 +960,246 @@ export class TypicalTransformer {
1045
960
  return shouldTransformFile(fileName, this.config);
1046
961
  }
1047
962
 
963
+ /**
964
+ * Check if a TypeNode represents any or unknown type.
965
+ * These types are intentional escape hatches and shouldn't be validated.
966
+ */
967
+ private isAnyOrUnknownType(typeNode: ts.TypeNode): boolean {
968
+ return typeNode.kind === this.ts.SyntaxKind.AnyKeyword ||
969
+ typeNode.kind === this.ts.SyntaxKind.UnknownKeyword;
970
+ }
971
+
972
+ /**
973
+ * Check if a Type has any or unknown flags.
974
+ */
975
+ private isAnyOrUnknownTypeFlags(type: ts.Type): boolean {
976
+ return (type.flags & this.ts.TypeFlags.Any) !== 0 ||
977
+ (type.flags & this.ts.TypeFlags.Unknown) !== 0;
978
+ }
979
+
980
+ /**
981
+ * Check if a type name matches any of the ignoreTypes patterns.
982
+ * Supports wildcards: "React.*" matches "React.FormEvent", "React.ChangeEvent", etc.
983
+ */
984
+ private isIgnoredType(typeName: string): boolean {
985
+ const patterns = this.config.ignoreTypes ?? [];
986
+ if (patterns.length === 0) return false;
987
+
988
+ return patterns.some(pattern => {
989
+ // Convert glob pattern to regex: "React.*" -> /^React\..*$/
990
+ const regexStr = '^' + pattern
991
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except *
992
+ .replace(/\*/g, '.*') + '$';
993
+ return new RegExp(regexStr).test(typeName);
994
+ });
995
+ }
996
+
997
+ /**
998
+ * Find untransformed typia calls in the output code.
999
+ * These indicate types that typia could not process.
1000
+ */
1001
+ private findUntransformedTypiaCalls(code: string): Array<{ method: string; type: string }> {
1002
+ const results: Array<{ method: string; type: string }> = [];
1003
+
1004
+ // Match patterns like: typia.createAssert<Type>() or typia.json.createAssertParse<Type>()
1005
+ // The type argument can contain nested generics like React.FormEvent<HTMLElement>
1006
+ const patterns = [
1007
+ /typia\.createAssert<([^>]+(?:<[^>]*>)?)>\s*\(\)/g,
1008
+ /typia\.json\.createAssertParse<([^>]+(?:<[^>]*>)?)>\s*\(\)/g,
1009
+ /typia\.json\.createStringify<([^>]+(?:<[^>]*>)?)>\s*\(\)/g,
1010
+ ];
1011
+
1012
+ for (const pattern of patterns) {
1013
+ let match;
1014
+ while ((match = pattern.exec(code)) !== null) {
1015
+ const methodMatch = match[0].match(/typia\.([\w.]+)</);
1016
+ results.push({
1017
+ method: methodMatch ? methodMatch[1] : 'unknown',
1018
+ type: match[1]
1019
+ });
1020
+ }
1021
+ }
1022
+
1023
+ return results;
1024
+ }
1025
+
1026
+ /**
1027
+ * Infer type information from a JSON.stringify argument for creating a reusable stringifier.
1028
+ */
1029
+ private inferStringifyType(
1030
+ arg: ts.Expression,
1031
+ typeChecker: ts.TypeChecker,
1032
+ ctx: TransformContext
1033
+ ): { typeText: string; typeNode: ts.TypeNode } {
1034
+ const ts = this.ts;
1035
+
1036
+ // Type assertion: use the asserted type directly
1037
+ if (ts.isAsExpression(arg)) {
1038
+ const typeNode = arg.type;
1039
+ const typeString = typeChecker.typeToString(typeChecker.getTypeFromTypeNode(typeNode));
1040
+ return {
1041
+ typeText: `Asserted_${typeString.replace(/[^a-zA-Z0-9_]/g, "_")}`,
1042
+ typeNode,
1043
+ };
1044
+ }
1045
+
1046
+ // Object literal: use property names for the key
1047
+ if (ts.isObjectLiteralExpression(arg)) {
1048
+ const objectType = typeChecker.getTypeAtLocation(arg);
1049
+ const typeNode = typeChecker.typeToTypeNode(objectType, arg, ts.NodeBuilderFlags.InTypeAlias);
1050
+ if (!typeNode) {
1051
+ throw new Error('unknown type node for object literal: ' + arg.getText());
1052
+ }
1053
+ const propNames = arg.properties
1054
+ .map((prop) => {
1055
+ if (ts.isShorthandPropertyAssignment(prop)) return prop.name.text;
1056
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) return prop.name.text;
1057
+ return "unknown";
1058
+ })
1059
+ .sort()
1060
+ .join("_");
1061
+ return { typeText: `ObjectLiteral_${propNames}`, typeNode };
1062
+ }
1063
+
1064
+ // Other expressions: infer from type checker
1065
+ const argType = typeChecker.getTypeAtLocation(arg);
1066
+ const typeNode = typeChecker.typeToTypeNode(argType, arg, ts.NodeBuilderFlags.InTypeAlias);
1067
+ if (typeNode) {
1068
+ const typeString = typeChecker.typeToString(argType);
1069
+ return {
1070
+ typeText: `Expression_${typeString.replace(/[^a-zA-Z0-9_]/g, "_")}`,
1071
+ typeNode,
1072
+ };
1073
+ }
1074
+
1075
+ // Fallback to unknown
1076
+ return {
1077
+ typeText: "unknown",
1078
+ typeNode: ctx.factory.createKeywordTypeNode(ctx.ts.SyntaxKind.UnknownKeyword),
1079
+ };
1080
+ }
1081
+
1082
+ // ============================================
1083
+ // Flow Analysis Helpers
1084
+ // ============================================
1085
+
1086
+ /**
1087
+ * Gets the root identifier from an expression.
1088
+ * e.g., `user.address.city` -> "user"
1089
+ */
1090
+ private getRootIdentifier(expr: ts.Expression): string | undefined {
1091
+ if (this.ts.isIdentifier(expr)) {
1092
+ return expr.text;
1093
+ }
1094
+ if (this.ts.isPropertyAccessExpression(expr)) {
1095
+ return this.getRootIdentifier(expr.expression);
1096
+ }
1097
+ return undefined;
1098
+ }
1099
+
1100
+ /**
1101
+ * Check if a validated variable has been tainted (mutated) in the function body.
1102
+ * A variable is tainted if it's reassigned, has properties modified, is passed
1103
+ * to a function, has methods called on it, or if an await occurs.
1104
+ */
1105
+ private isTainted(varName: string, body: ts.Block): boolean {
1106
+ let tainted = false;
1107
+ const ts = this.ts;
1108
+
1109
+ // Collect aliases (variables that reference properties of varName)
1110
+ // e.g., const addr = user.address; -> addr is an alias
1111
+ const aliases = new Set<string>([varName]);
1112
+
1113
+ const collectAliases = (node: ts.Node): void => {
1114
+ if (ts.isVariableStatement(node)) {
1115
+ for (const decl of node.declarationList.declarations) {
1116
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
1117
+ const initRoot = this.getRootIdentifier(decl.initializer);
1118
+ if (initRoot && aliases.has(initRoot)) {
1119
+ aliases.add(decl.name.text);
1120
+ }
1121
+ }
1122
+ }
1123
+ }
1124
+ ts.forEachChild(node, collectAliases);
1125
+ };
1126
+ collectAliases(body);
1127
+
1128
+ // Helper to check if any alias is involved
1129
+ const involvesTrackedVar = (expr: ts.Expression): boolean => {
1130
+ const root = this.getRootIdentifier(expr);
1131
+ return root !== undefined && aliases.has(root);
1132
+ };
1133
+
1134
+ const checkTainting = (node: ts.Node): void => {
1135
+ if (tainted) return;
1136
+
1137
+ // Reassignment: trackedVar = ...
1138
+ if (ts.isBinaryExpression(node) &&
1139
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
1140
+ ts.isIdentifier(node.left) &&
1141
+ aliases.has(node.left.text)) {
1142
+ tainted = true;
1143
+ return;
1144
+ }
1145
+
1146
+ // Property assignment: trackedVar.x = ... or alias.x = ...
1147
+ if (ts.isBinaryExpression(node) &&
1148
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
1149
+ ts.isPropertyAccessExpression(node.left) &&
1150
+ involvesTrackedVar(node.left)) {
1151
+ tainted = true;
1152
+ return;
1153
+ }
1154
+
1155
+ // Element assignment: trackedVar[x] = ... or alias[x] = ...
1156
+ if (ts.isBinaryExpression(node) &&
1157
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
1158
+ ts.isElementAccessExpression(node.left) &&
1159
+ involvesTrackedVar(node.left.expression)) {
1160
+ tainted = true;
1161
+ return;
1162
+ }
1163
+
1164
+ // Passed as argument to a function: fn(trackedVar) or fn(alias)
1165
+ if (ts.isCallExpression(node)) {
1166
+ for (const arg of node.arguments) {
1167
+ let hasTrackedRef = false;
1168
+ const checkRef = (n: ts.Node): void => {
1169
+ if (ts.isIdentifier(n) && aliases.has(n.text)) {
1170
+ hasTrackedRef = true;
1171
+ }
1172
+ ts.forEachChild(n, checkRef);
1173
+ };
1174
+ checkRef(arg);
1175
+ if (hasTrackedRef) {
1176
+ tainted = true;
1177
+ return;
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ // Method call on the variable: trackedVar.method() or alias.method()
1183
+ if (ts.isCallExpression(node) &&
1184
+ ts.isPropertyAccessExpression(node.expression) &&
1185
+ involvesTrackedVar(node.expression.expression)) {
1186
+ tainted = true;
1187
+ return;
1188
+ }
1189
+
1190
+ // Await expression (async boundary - external code could run)
1191
+ if (ts.isAwaitExpression(node)) {
1192
+ tainted = true;
1193
+ return;
1194
+ }
1195
+
1196
+ ts.forEachChild(node, checkTainting);
1197
+ };
1198
+
1199
+ checkTainting(body);
1200
+ return tainted;
1201
+ }
1202
+
1048
1203
  private addTypiaImport(
1049
1204
  sourceFile: ts.SourceFile,
1050
1205
  ctx: TransformContext
@@ -1083,135 +1238,153 @@ export class TypicalTransformer {
1083
1238
  return sourceFile;
1084
1239
  }
1085
1240
 
1086
- private getOrCreateValidator(
1087
- typeText: string,
1088
- typeNode: ts.TypeNode
1089
- ): string {
1090
- if (this.typeValidators.has(typeText)) {
1091
- return this.typeValidators.get(typeText)!.name;
1241
+ /**
1242
+ * Gets type text for use as a validator map key.
1243
+ * Uses getText() to preserve local aliases (e.g., "User1" vs "User2"),
1244
+ * but falls back to typeToString() for synthesized nodes without source positions.
1245
+ *
1246
+ * @param typeNode The TypeNode to get a key for
1247
+ * @param typeChecker The TypeChecker to use
1248
+ * @param typeObj Optional Type object - use this for synthesized nodes since
1249
+ * getTypeFromTypeNode doesn't work correctly on them
1250
+ */
1251
+ private getTypeKey(typeNode: ts.TypeNode, typeChecker: ts.TypeChecker, typeObj?: ts.Type): string {
1252
+ // Check if node has a real position (not synthesized)
1253
+ if (typeNode.pos >= 0 && typeNode.end > typeNode.pos) {
1254
+ try {
1255
+ return typeNode.getText();
1256
+ } catch {
1257
+ // Fall through to typeToString
1258
+ }
1092
1259
  }
1260
+ // Fallback for synthesized nodes - use the provided Type object if available,
1261
+ // otherwise try to get it from the node (which may not work correctly)
1262
+ const type = typeObj ?? typeChecker.getTypeFromTypeNode(typeNode);
1263
+ return typeChecker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation);
1264
+ }
1093
1265
 
1094
- const validatorName = `__typical_` + `assert_${this.typeValidators.size}`;
1095
- this.typeValidators.set(typeText, { name: validatorName, typeNode });
1096
- return validatorName;
1266
+ /**
1267
+ * Creates a readable name suffix from a type string.
1268
+ * For simple identifiers like "User" or "string", returns the name directly.
1269
+ * For complex types, returns a numeric index.
1270
+ */
1271
+ private getTypeNameSuffix(typeText: string, existingNames: Set<string>, fallbackIndex: number): string {
1272
+ // Strip known prefixes that wrap the actual type name
1273
+ let normalizedTypeText = typeText;
1274
+ if (typeText.startsWith('Expression_')) {
1275
+ normalizedTypeText = typeText.slice('Expression_'.length);
1276
+ } else if (typeText.startsWith('ObjectLiteral_')) {
1277
+ // Object literals use property names, fall back to numeric
1278
+ return String(fallbackIndex);
1279
+ }
1280
+
1281
+ // Check if it's a simple identifier (letters, numbers, underscore, starting with letter/underscore)
1282
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(normalizedTypeText)) {
1283
+ // It's a simple type name like "User", "string", "MyType"
1284
+ let name = normalizedTypeText;
1285
+ // Handle collisions by appending a number
1286
+ if (existingNames.has(name)) {
1287
+ let i = 2;
1288
+ while (existingNames.has(`${normalizedTypeText}${i}`)) {
1289
+ i++;
1290
+ }
1291
+ name = `${normalizedTypeText}${i}`;
1292
+ }
1293
+ return name;
1294
+ }
1295
+ // Complex type - use numeric index
1296
+ return String(fallbackIndex);
1097
1297
  }
1098
1298
 
1099
- private getOrCreateStringifier(
1299
+ /**
1300
+ * Generic method to get or create a typed function (validator, stringifier, or parser).
1301
+ */
1302
+ private getOrCreateTypedFunction(
1303
+ kind: 'assert' | 'stringify' | 'parse',
1100
1304
  typeText: string,
1101
1305
  typeNode: ts.TypeNode
1102
1306
  ): string {
1103
- if (this.typeStringifiers.has(typeText)) {
1104
- return this.typeStringifiers.get(typeText)!.name;
1307
+ const maps = {
1308
+ assert: this.typeValidators,
1309
+ stringify: this.typeStringifiers,
1310
+ parse: this.typeParsers,
1311
+ };
1312
+ const prefixes = {
1313
+ assert: '__typical_assert_',
1314
+ stringify: '__typical_stringify_',
1315
+ parse: '__typical_parse_',
1316
+ };
1317
+
1318
+ const map = maps[kind];
1319
+ const prefix = prefixes[kind];
1320
+
1321
+ if (map.has(typeText)) {
1322
+ return map.get(typeText)!.name;
1105
1323
  }
1106
1324
 
1107
- const stringifierName = `__typical_` + `stringify_${this.typeStringifiers.size}`;
1108
- this.typeStringifiers.set(typeText, { name: stringifierName, typeNode });
1109
- return stringifierName;
1325
+ const existingSuffixes = [...map.values()].map(v => v.name.slice(prefix.length));
1326
+ const existingNames = new Set(existingSuffixes);
1327
+ const numericCount = existingSuffixes.filter(s => /^\d+$/.test(s)).length;
1328
+ const suffix = this.getTypeNameSuffix(typeText, existingNames, numericCount);
1329
+ const name = `${prefix}${suffix}`;
1330
+ map.set(typeText, { name, typeNode });
1331
+ return name;
1332
+ }
1333
+
1334
+ private getOrCreateValidator(typeText: string, typeNode: ts.TypeNode): string {
1335
+ return this.getOrCreateTypedFunction('assert', typeText, typeNode);
1336
+ }
1337
+
1338
+ private getOrCreateStringifier(typeText: string, typeNode: ts.TypeNode): string {
1339
+ return this.getOrCreateTypedFunction('stringify', typeText, typeNode);
1110
1340
  }
1111
1341
 
1112
1342
  private getOrCreateParser(typeText: string, typeNode: ts.TypeNode): string {
1113
- if (this.typeParsers.has(typeText)) {
1114
- return this.typeParsers.get(typeText)!.name;
1115
- }
1343
+ return this.getOrCreateTypedFunction('parse', typeText, typeNode);
1344
+ }
1116
1345
 
1117
- const parserName = `__typical_` + `parse_${this.typeParsers.size}`;
1118
- this.typeParsers.set(typeText, { name: parserName, typeNode });
1119
- return parserName;
1346
+ /**
1347
+ * Creates a nested property access expression from an array of identifiers.
1348
+ * e.g., ['typia', 'json', 'createStringify'] -> typia.json.createStringify
1349
+ */
1350
+ private createPropertyAccessChain(factory: ts.NodeFactory, parts: string[]): ts.Expression {
1351
+ let expr: ts.Expression = factory.createIdentifier(parts[0]);
1352
+ for (let i = 1; i < parts.length; i++) {
1353
+ expr = factory.createPropertyAccessExpression(expr, parts[i]);
1354
+ }
1355
+ return expr;
1120
1356
  }
1121
1357
 
1122
1358
  private createValidatorStatements(ctx: TransformContext): ts.Statement[] {
1123
1359
  const { factory } = ctx;
1124
1360
  const statements: ts.Statement[] = [];
1125
1361
 
1126
- // Create assert validators
1127
- for (const [, { name: validatorName, typeNode }] of this.typeValidators) {
1128
- const createAssertCall = factory.createCallExpression(
1129
- factory.createPropertyAccessExpression(
1130
- factory.createIdentifier("typia"),
1131
- "createAssert"
1132
- ),
1133
- [typeNode],
1134
- []
1135
- );
1136
-
1137
- const validatorDeclaration = factory.createVariableStatement(
1138
- undefined,
1139
- factory.createVariableDeclarationList(
1140
- [
1141
- factory.createVariableDeclaration(
1142
- validatorName,
1143
- undefined,
1144
- undefined,
1145
- createAssertCall
1146
- ),
1147
- ],
1148
- ctx.ts.NodeFlags.Const
1149
- )
1150
- );
1151
- statements.push(validatorDeclaration);
1152
- }
1153
-
1154
- // Create stringifiers
1155
- for (const [, { name: stringifierName, typeNode }] of this
1156
- .typeStringifiers) {
1157
- const createStringifyCall = factory.createCallExpression(
1158
- factory.createPropertyAccessExpression(
1159
- factory.createPropertyAccessExpression(
1160
- factory.createIdentifier("typia"),
1161
- "json"
1162
- ),
1163
- "createStringify"
1164
- ),
1165
- [typeNode],
1166
- []
1167
- );
1168
-
1169
- const stringifierDeclaration = factory.createVariableStatement(
1170
- undefined,
1171
- factory.createVariableDeclarationList(
1172
- [
1173
- factory.createVariableDeclaration(
1174
- stringifierName,
1175
- undefined,
1176
- undefined,
1177
- createStringifyCall
1178
- ),
1179
- ],
1180
- ctx.ts.NodeFlags.Const
1181
- )
1182
- );
1183
- statements.push(stringifierDeclaration);
1184
- }
1185
-
1186
- // Create parsers
1187
- for (const [, { name: parserName, typeNode }] of this.typeParsers) {
1188
- const createParseCall = factory.createCallExpression(
1189
- factory.createPropertyAccessExpression(
1190
- factory.createPropertyAccessExpression(
1191
- factory.createIdentifier("typia"),
1192
- "json"
1193
- ),
1194
- "createAssertParse"
1195
- ),
1196
- [typeNode],
1197
- []
1198
- );
1362
+ const configs: Array<{
1363
+ map: Map<string, { name: string; typeNode: ts.TypeNode }>;
1364
+ methodPath: string[];
1365
+ }> = [
1366
+ { map: this.typeValidators, methodPath: ['typia', 'createAssert'] },
1367
+ { map: this.typeStringifiers, methodPath: ['typia', 'json', 'createStringify'] },
1368
+ { map: this.typeParsers, methodPath: ['typia', 'json', 'createAssertParse'] },
1369
+ ];
1370
+
1371
+ for (const { map, methodPath } of configs) {
1372
+ for (const [, { name, typeNode }] of map) {
1373
+ const createCall = factory.createCallExpression(
1374
+ this.createPropertyAccessChain(factory, methodPath),
1375
+ [typeNode],
1376
+ []
1377
+ );
1199
1378
 
1200
- const parserDeclaration = factory.createVariableStatement(
1201
- undefined,
1202
- factory.createVariableDeclarationList(
1203
- [
1204
- factory.createVariableDeclaration(
1205
- parserName,
1206
- undefined,
1207
- undefined,
1208
- createParseCall
1209
- ),
1210
- ],
1211
- ctx.ts.NodeFlags.Const
1212
- )
1213
- );
1214
- statements.push(parserDeclaration);
1379
+ const declaration = factory.createVariableStatement(
1380
+ undefined,
1381
+ factory.createVariableDeclarationList(
1382
+ [factory.createVariableDeclaration(name, undefined, undefined, createCall)],
1383
+ ctx.ts.NodeFlags.Const
1384
+ )
1385
+ );
1386
+ statements.push(declaration);
1387
+ }
1215
1388
  }
1216
1389
 
1217
1390
  return statements;