Skip to content

Commit 1e7de80

Browse files
committed
feat: support typescript v6
1 parent 4bc005f commit 1e7de80

43 files changed

Lines changed: 169 additions & 100 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/runTestsOnPush.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
matrix:
99
node-version: [22]
1010
os: [ubuntu-latest]
11-
typescript-version: ["^5.0.0"]
11+
typescript-version: ["^6.0.0"]
1212

1313
steps:
1414
- uses: actions/checkout@master

memory/MEMORY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Memory Index
2+
3+
- [TypeScript v6 upgrade fixes](project_ts6_upgrade.md) — TS v6 breaking changes fixed in typeResolver.ts, specGenerator2/3.ts, and test expectations

memory/project_ts6_upgrade.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
name: project_ts6_upgrade
3+
description: "TypeScript v6 upgrade — breaking changes fixed in tsoa's type resolver and swagger generators"
4+
metadata:
5+
node_type: memory
6+
type: project
7+
originSessionId: 55aaffb4-ee2d-4c95-9ce0-76947e423b97
8+
---
9+
10+
TypeScript v6 was upgraded in this repo. All unit tests (1111) now pass after fixes.
11+
12+
**Why:** TS v6 changed type checker behavior in two ways relevant to tsoa.
13+
14+
**Key fixes applied:**
15+
16+
1. **`typeResolver.ts` `calcMappedType` (line ~210)**: TS v6 `getTypeOfSymbol()` for optional properties in mapped types now includes `undefined` (e.g., `"id" | undefined` instead of `"id"`). Fix: strip the single `undefined` constituent when the property is optional (`SymbolFlags.Optional`) and only one non-undefined type remains.
17+
18+
2. **`typeResolver.ts` union TypeNode ordering (line ~65)**: TS v6 UnionType `.types` order may differ from the UnionTypeNode `.types` order. Fix: semantic matching for synthetic TypeNodes (pos === -1) instead of index-based — UndefinedKeyword matches undefined type, non-keyword matches non-null/undefined type.
19+
20+
3. **`typeResolver.ts` MappedTypeNode guard (line ~441)**: Skip `symbol.valueDeclaration` when it's a MappedTypeNode, since `.type` on such nodes is the unresolved template.
21+
22+
4. **`typeResolver.ts` synthetic node referencer fallback (line ~796)**: For synthetic TypeNodes (pos === -1), fall back to `this.referencer` instead of calling `typeChecker.getTypeFromTypeNode()` which fails.
23+
24+
5. **`typeResolver.ts` keyword-indexed accessor (line ~445)**: Pass `type` as 5th arg (referencer) to TypeResolver.
25+
26+
6. **`specGenerator2.ts` union type**: Added `typesWithoutUndefined.length === 1` shortcut in `getSwaggerTypeForUnionType` to handle optional enum properties becoming `T | undefined` in TS v6.
27+
28+
7. **Test expectations**: Updated `boolValue*` test expectations from `type: 'string'` to `type: 'boolean'` — TS v6 changed conditional type evaluation: `boolean | undefined extends boolean` = `false` (not `true`).
29+
30+
**How to apply:** If TS is upgraded again, check these same locations for API behavior changes.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"typedoc": "^0.26.7"
5757
},
5858
"resolutions": {
59-
"typescript": "^5.2.2"
59+
"typescript": "^6.0.0"
6060
},
6161
"repository": {
6262
"type": "git",

packages/cli/src/metadataGeneration/typeResolver.ts

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,21 @@ export class TypeResolver {
6363
}
6464

6565
if (ts.isUnionTypeNode(this.typeNode)) {
66+
const referencerUnionTypes = this.referencer?.isUnion() ? this.referencer.types : undefined;
6667
const types = this.typeNode.types.map(type => {
67-
return new TypeResolver(type, this.current, this.parentNode, this.context).resolve();
68+
let memberReferencer: ts.Type | undefined;
69+
if (referencerUnionTypes && this.typeNode.pos === -1) {
70+
// Synthetic union TypeNode: match member by type flag rather than position,
71+
// since typeToTypeNode and union .types may be in different order.
72+
if (type.kind === ts.SyntaxKind.UndefinedKeyword) {
73+
memberReferencer = referencerUnionTypes.find(t => !!(t.flags & ts.TypeFlags.Undefined));
74+
} else if (type.kind === ts.SyntaxKind.NullKeyword) {
75+
memberReferencer = referencerUnionTypes.find(t => !!(t.flags & ts.TypeFlags.Null));
76+
} else {
77+
memberReferencer = referencerUnionTypes.find(t => !(t.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null)));
78+
}
79+
}
80+
return new TypeResolver(type, this.current, this.parentNode, this.context, memberReferencer).resolve();
6881
});
6982

7083
const unionMetaType: Tsoa.UnionType = {
@@ -194,13 +207,23 @@ export class TypeResolver {
194207
.filter(property => isIgnored(property) === false)
195208
// Transform to property
196209
.map(property => {
197-
const propertyType = this.current.typeChecker.getTypeOfSymbolAtLocation(property, this.typeNode);
210+
const propertyType = this.current.typeChecker.getTypeOfSymbol(property);
211+
const required = !this.hasFlag(property, ts.SymbolFlags.Optional);
198212

199-
const typeNode = this.current.typeChecker.typeToTypeNode(propertyType, undefined, ts.NodeBuilderFlags.NoTruncation)!;
200-
const parent = getOneOrigDeclaration(property); //If there are more declarations, we need to get one of them, from where we want to recognize jsDoc
201-
const type = new TypeResolver(typeNode, this.current, parent, this.context, propertyType).resolve();
213+
// TypeScript v6 includes `undefined` in getTypeOfSymbol for optional properties.
214+
// Strip it since optionality is encoded in `required` and including undefined
215+
// causes enum types to go through the union path (losing explicit nullable: false).
216+
let effectiveType: ts.Type = propertyType;
217+
if (!required && propertyType.isUnion()) {
218+
const withoutUndefined = propertyType.types.filter(t => !(t.flags & ts.TypeFlags.Undefined));
219+
if (withoutUndefined.length === 1) {
220+
effectiveType = withoutUndefined[0];
221+
}
222+
}
202223

203-
const required = !this.hasFlag(property, ts.SymbolFlags.Optional);
224+
const typeNode = this.current.typeChecker.typeToTypeNode(effectiveType, undefined, ts.NodeBuilderFlags.NoTruncation)!;
225+
const parent = getOneOrigDeclaration(property); //If there are more declarations, we need to get one of them, from where we want to recognize jsDoc
226+
const type = new TypeResolver(typeNode, this.current, parent, this.context, effectiveType).resolve();
204227

205228
const comments = property.getDocumentationComment(this.current.typeChecker);
206229
const description = comments.length ? ts.displayPartsToString(comments) : undefined;
@@ -316,7 +339,7 @@ export class TypeResolver {
316339
if (type.isIndexType()) {
317340
// in case of generic: keyof T. Not handles all possible cases
318341
const symbol = type.type.getSymbol();
319-
if (symbol && symbol.getFlags() & ts.TypeFlags.TypeParameter) {
342+
if (symbol && symbol.getFlags() & ts.SymbolFlags.TypeParameter) {
320343
const typeName = symbol.getEscapedName();
321344
throwUnless(typeof typeName === 'string', new GenerateMetadataError(`typeName is not string, but ${typeof typeName}`, typeNode));
322345

@@ -428,18 +451,18 @@ export class TypeResolver {
428451
const typeOfObjectType = typeChecker.getTypeFromTypeNode(objectType);
429452
const type = isNumberIndexType ? typeOfObjectType.getNumberIndexType() : typeOfObjectType.getStringIndexType();
430453
throwUnless(type, new GenerateMetadataError(`Could not determine ${isNumberIndexType ? 'number' : 'string'} index on ${typeChecker.typeToString(typeOfObjectType)}`, typeNode));
431-
return new TypeResolver(typeChecker.typeToTypeNode(type, objectType, ts.NodeBuilderFlags.NoTruncation)!, current, typeNode, context).resolve();
454+
return new TypeResolver(typeChecker.typeToTypeNode(type, objectType, ts.NodeBuilderFlags.NoTruncation)!, current, typeNode, context, type).resolve();
432455
} else if (ts.isLiteralTypeNode(indexType) && (ts.isStringLiteral(indexType.literal) || ts.isNumericLiteral(indexType.literal))) {
433456
// Indexed by literal
434457
const hasType = (node: ts.Node | undefined): node is ts.HasType => node !== undefined && Object.prototype.hasOwnProperty.call(node, 'type');
435458
const symbol = typeChecker.getPropertyOfType(typeChecker.getTypeFromTypeNode(objectType), indexType.literal.text);
436459
throwUnless(symbol, new GenerateMetadataError(`Could not determine the keys on ${typeChecker.typeToString(typeChecker.getTypeFromTypeNode(objectType))}`, typeNode));
437-
if (hasType(symbol.valueDeclaration) && symbol.valueDeclaration.type) {
460+
if (hasType(symbol.valueDeclaration) && symbol.valueDeclaration.type && !ts.isMappedTypeNode(symbol.valueDeclaration)) {
438461
return new TypeResolver(symbol.valueDeclaration.type, current, typeNode, context).resolve();
439462
}
440463
const declaration = typeChecker.getTypeOfSymbolAtLocation(symbol, objectType);
441464
try {
442-
return new TypeResolver(typeChecker.typeToTypeNode(declaration, objectType, ts.NodeBuilderFlags.NoTruncation)!, current, typeNode, context).resolve();
465+
return new TypeResolver(typeChecker.typeToTypeNode(declaration, objectType, ts.NodeBuilderFlags.NoTruncation)!, current, typeNode, context, declaration).resolve();
443466
} catch {
444467
throw new GenerateMetadataError(
445468
`Could not determine the keys on ${typeChecker.typeToString(typeChecker.getTypeFromTypeNode(typeChecker.typeToTypeNode(declaration, undefined, ts.NodeBuilderFlags.NoTruncation)!))}`,
@@ -789,7 +812,7 @@ export class TypeResolver {
789812
const referenceTypes: Tsoa.ReferenceType[] = [];
790813
for (const declaration of declarations) {
791814
if (ts.isTypeAliasDeclaration(declaration)) {
792-
const referencer = node.pos !== -1 ? this.current.typeChecker.getTypeFromTypeNode(node) : undefined;
815+
const referencer = node.pos !== -1 ? this.current.typeChecker.getTypeFromTypeNode(node) : this.referencer;
793816
referenceTypes.push(new ReferenceTransformer().transform(declaration, refTypeName, this, referencer));
794817
} else if (EnumTransformer.transformable(declaration)) {
795818
referenceTypes.push(new EnumTransformer().transform(this, declaration, refTypeName));
@@ -950,9 +973,14 @@ export class TypeResolver {
950973

951974
if (modelTypes.length > 1) {
952975
// remove types that are from typescript e.g. 'Account'
953-
modelTypes = modelTypes.filter(modelType => {
976+
// Only apply filter if it leaves at least one declaration; built-ins like Error have all
977+
// declarations inside node_modules/typescript (TypeScript v6 ships multiple lib files).
978+
const nonTsModelTypes = modelTypes.filter(modelType => {
954979
return modelType.getSourceFile().fileName.replace(/\\/g, '/').toLowerCase().indexOf('node_modules/typescript') <= -1;
955980
});
981+
if (nonTsModelTypes.length) {
982+
modelTypes = nonTsModelTypes;
983+
}
956984

957985
modelTypes = this.getDesignatedModels(modelTypes, typeName);
958986
}

packages/cli/src/swagger/specGenerator2.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,10 @@ export class SpecGenerator2 extends SpecGenerator {
455455
swaggerType['x-nullable'] = true;
456456
return swaggerType;
457457
}
458+
} else if (typesWithoutUndefined.length === 1) {
459+
// A type that is optional (T | undefined) — return the underlying type directly.
460+
// In Swagger 2, optionality is expressed via the required array, not the type itself.
461+
return this.getSwaggerType(typesWithoutUndefined[0]);
458462
} else if (process.env.NODE_ENV !== 'tsoa_test') {
459463
// eslint-disable-next-line no-console
460464
console.warn('Swagger 2.0 does not support union types beyond string literals.\n' + 'If you would like to take advantage of this, please change tsoa.json\'s "specVersion" to 3.');

packages/cli/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lib": ["es2021"],
1010
"typeRoots": ["node_modules/@types", "./typings"],
1111
"downlevelIteration": true,
12+
"ignoreDeprecations": "6.0",
1213
"experimentalDecorators": true,
1314
"declaration": true,
1415
"noUnusedLocals": true,

packages/runtime/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"lib": ["es2021"],
1111
"typeRoots": ["node_modules/@types", "./typings"],
1212
"downlevelIteration": true,
13+
"ignoreDeprecations": "6.0",
1314
"experimentalDecorators": true,
1415
"declaration": true,
1516
"noUnusedLocals": true,

packages/tsoa/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lib": ["es2021"],
1010
"typeRoots": ["node_modules/@types", "./typings"],
1111
"downlevelIteration": true,
12+
"ignoreDeprecations": "6.0",
1213
"experimentalDecorators": true,
1314
"declaration": true,
1415
"noUnusedLocals": true,

tests/.mocharc.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,5 @@
44
"exclude": ["esm/**/*"],
55
"require": ["ts-node/register", "tsconfig-paths/register"],
66
"exit": true,
7-
"colors": true,
8-
"node-option": ["no-experimental-strip-types"]
7+
"colors": true
98
}

0 commit comments

Comments
 (0)