From a29dca1b1910997d5bbe066d5e41eba5da905af0 Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Wed, 20 May 2026 01:41:12 +0100 Subject: [PATCH 1/5] feat(typescript): emit scalar declarations as type aliases and preserve scalar names in fields and maps Signed-off-by: muhammed-abdulkadir --- .../fromcto/typescript/typescriptvisitor.js | 56 +++++-- test/codegen/__snapshots__/codegen.js.snap | 22 ++- .../fromcto/typescript/typescriptvisitor.js | 158 ++++++++++++++++-- 3 files changed, 197 insertions(+), 39 deletions(-) diff --git a/lib/codegen/fromcto/typescript/typescriptvisitor.js b/lib/codegen/fromcto/typescript/typescriptvisitor.js index d6a34c3..749a028 100644 --- a/lib/codegen/fromcto/typescript/typescriptvisitor.js +++ b/lib/codegen/fromcto/typescript/typescriptvisitor.js @@ -40,6 +40,8 @@ class TypescriptVisitor { return this.visitModelManager(thing, parameters); } else if (thing.isModelFile?.()) { return this.visitModelFile(thing, parameters); + } else if (thing.isScalarDeclaration?.()) { + return this.visitScalarDeclaration(thing, parameters); } else if (thing.isEnum?.()) { return this.visitEnumDeclaration(thing, parameters); } else if (thing.isClassDeclaration?.()) { @@ -47,10 +49,7 @@ class TypescriptVisitor { } else if (thing.isMapDeclaration?.()) { return this.visitMapDeclaration(thing, parameters); } else if (thing.isTypeScalar?.()) { - // Propagate optional modifier from the field to visitField via parameters - // Avoid mutating the shared scalar descriptor - const scalarField = thing.getScalarField(); - return this.visitField(scalarField, { ...parameters, forceOptional: thing.isOptional() }); + return this.visitScalarField(thing, parameters); } else if (thing.isField?.()) { return this.visitField(thing, parameters); } else if (thing.isRelationship?.()) { @@ -123,7 +122,8 @@ class TypescriptVisitor { if (!properties.has(typeNamespace)) { properties.set(typeNamespace, new Set()); } - properties.get(typeNamespace).add(property.isTypeEnum?.() ? typeName : `I${typeName}`); + properties.get(typeNamespace).add( + property.isTypeEnum?.() || property.isTypeScalar?.() ? typeName : `I${typeName}`); } }); @@ -169,10 +169,9 @@ class TypescriptVisitor { parameters.fileWriter.writeLine(0, '\n// interfaces'); parameters.aliasedTypesMap = aliasedTypesMap; - modelFile.getAllDeclarations() - .filter(declaration => !declaration.isScalarDeclaration?.()).forEach((decl) => { - decl.accept(this, parameters); - }); + modelFile.getAllDeclarations().forEach((decl) => { + decl.accept(this, parameters); + }); parameters.fileWriter.closeFile(); @@ -235,6 +234,19 @@ class TypescriptVisitor { return null; } + /** + * Visitor design pattern + * @param {Object} scalarDeclaration - the object being visited + * @param {Object} parameters - the parameter + * @return {Object} the result of visiting or null + * @private + */ + visitScalarDeclaration(scalarDeclaration, parameters) { + const tsType = this.toTsType(scalarDeclaration.getType(), false, false); + parameters.fileWriter.writeLine(0, `export type ${scalarDeclaration.getName()} = ${tsType};\n`); + return null; + } + /** * Visitor design pattern * @param {Field} field - the object being visited @@ -287,6 +299,20 @@ class TypescriptVisitor { return null; } + /** + * Visitor design pattern - handles fields whose type is a scalar declaration + * @param {Object} field - the scalar-typed field being visited + * @param {Object} parameters - the parameter + * @return {Object} the result of visiting or null + * @private + */ + visitScalarField(field, parameters) { + let array = field.isArray() ? '[]' : ''; + let optional = field.isOptional() ? '?' : ''; + parameters.fileWriter.writeLine(1, field.getName() + optional + ': ' + field.getType() + array + ';'); + return null; + } + /** * Visitor design pattern * @param {EnumValueDeclaration} enumValueDeclaration - the object being visited @@ -318,20 +344,18 @@ class TypescriptVisitor { if (ModelUtil.isPrimitiveType(mapKeyType)) { keyType = this.toTsType(mapDeclaration.getKey().getType(), false, false); } else if (ModelUtil.isScalar(mapDeclaration.getKey())) { - const scalarDeclaration = mapDeclaration.getModelFile().getType(mapDeclaration.getKey().getType()); - const scalarType = scalarDeclaration.getType(); - keyType = this.toTsType(scalarType, false, false); + keyType = mapKeyType; } // Map Value Type if (ModelUtil.isPrimitiveType(mapValueType)) { valueType = this.toTsType(mapDeclaration.getValue().getType(), false, false); } else if (ModelUtil.isScalar(mapDeclaration.getValue())) { - const scalarDeclaration = mapDeclaration.getModelFile().getType(mapDeclaration.getValue().getType()); - const scalarType = scalarDeclaration.getType(); - valueType = this.toTsType(scalarType, false, false); + valueType = mapValueType; } else { - valueType = this.toTsType(mapValueType, true, false); + const valueDecl = mapDeclaration.getModelFile().getType(mapValueType); + const isEnum = valueDecl?.isEnum?.(); + valueType = this.toTsType(mapValueType, !isEnum, false); } parameters.fileWriter.writeLine(0, 'export type ' + mapDeclaration.getName() + ` = Map<${keyType}, ${valueType}>;\n` ); diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index c483535..809a952 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -6892,7 +6892,7 @@ export enum TShirtSizeType { LARGE = 'LARGE', } -export type EmployeeTShirtSizes = Map; +export type EmployeeTShirtSizes = Map; export interface IAddress extends IConcept { street: string; @@ -6905,6 +6905,10 @@ export interface IAddress extends IConcept { export enum Level { } +export type Time = Date; + +export type SSN = string; + ", } `; @@ -6924,7 +6928,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio // Warning: Beware of circular dependencies when modifying these imports // Warning: Beware of circular dependencies when modifying these imports -import {IAddress,IEmployeeTShirtSizes,ISSN} from './org.acme.hr.base@1.0.0'; +import {IAddress,IEmployeeTShirtSizes,SSN} from './org.acme.hr.base@1.0.0'; import {IDecorator} from './concerto.decorator@1.0.0'; import {IConcept,IAsset,IParticipant,IEvent,ITransaction} from './concerto@1.0.0'; @@ -6943,15 +6947,15 @@ export interface IInfo extends IConcept { export type CompanyProperties = Map; -export type EmployeeLoginTimes = Map; +export type EmployeeLoginTimes = Map; -export type EmployeeSocialSecurityNumbers = Map; +export type EmployeeSocialSecurityNumbers = Map; -export type NextOfKin = Map; +export type NextOfKin = Map; export type EmployeeProfiles = Map; -export type EmployeeDirectory = Map; +export type EmployeeDirectory = Map; export interface ICompany extends IConcept { name: string; @@ -6993,7 +6997,7 @@ export interface IPerson extends IParticipant { lastName: string; middleNames?: string; homeAddress: IAddress; - ssn: string; + ssn: SSN; height: number; dob: Date; nextOfKin: NextOfKin; @@ -7038,6 +7042,10 @@ export interface IChangeOfAddress extends ITransaction { newAddress: IAddress; } +export type KinName = string; + +export type KinTelephone = string; + ", } `; diff --git a/test/codegen/fromcto/typescript/typescriptvisitor.js b/test/codegen/fromcto/typescript/typescriptvisitor.js index e42a27d..6eca3a0 100644 --- a/test/codegen/fromcto/typescript/typescriptvisitor.js +++ b/test/codegen/fromcto/typescript/typescriptvisitor.js @@ -124,6 +124,17 @@ describe('TypescriptVisitor', function () { mockSpecialVisit.calledWith(thing, param).should.be.ok; }); + it('should return visitScalarDeclaration for a ScalarDeclaration', () => { + let thing = sinon.createStubInstance(ScalarDeclaration); + thing.isScalarDeclaration.returns(true); + let mockSpecialVisit = sinon.stub(typescriptVisitor, 'visitScalarDeclaration'); + mockSpecialVisit.returns('Duck'); + + typescriptVisitor.visit(thing, param).should.deep.equal('Duck'); + + mockSpecialVisit.calledWith(thing, param).should.be.ok; + }); + it('should return visitMapDeclaration for a MapDeclaration', () => { let thing = sinon.createStubInstance(MapDeclaration); thing.isMapDeclaration.returns(true); @@ -135,15 +146,11 @@ describe('TypescriptVisitor', function () { mockSpecialVisit.calledWith(thing, param).should.be.ok; }); - it('should propagate optional modifier for scalar fields via parameters', () => { - // Create a mock scalar field - let mockScalarField = sinon.createStubInstance(Field); - mockScalarField.isOptional.returns(false); // Scalar field itself is not optional - - // Create a thing that is a scalar type field + it('should route scalar type fields to visitScalarField', () => { let thing = { isModelManager: () => false, isModelFile: () => false, + isScalarDeclaration: () => false, isEnum: () => false, isClassDeclaration: () => false, isMapDeclaration: () => false, @@ -151,21 +158,15 @@ describe('TypescriptVisitor', function () { isField: () => true, isRelationship: () => false, isEnumValue: () => false, - isOptional: () => true, // The parent field is optional - getScalarField: () => mockScalarField }; - let mockSpecialVisit = sinon.stub(typescriptVisitor, 'visitField'); + let mockSpecialVisit = sinon.stub(typescriptVisitor, 'visitScalarField'); mockSpecialVisit.returns('Duck'); typescriptVisitor.visit(thing, param); - // Verify that visitField was called with the scalar field mockSpecialVisit.calledOnce.should.be.ok; - // Verify that forceOptional was passed via parameters (not by mutating the scalar field) - const callArgs = mockSpecialVisit.getCall(0).args; - callArgs[0].should.equal(mockScalarField); - callArgs[1].forceOptional.should.equal(true); + mockSpecialVisit.calledWith(thing, param).should.be.ok; }); it('should throw an error when an unrecognised type is supplied', () => { @@ -502,6 +503,85 @@ describe('TypescriptVisitor', function () { }); }); + describe('visitScalarDeclaration', () => { + let param; + beforeEach(() => { + param = { + fileWriter: mockFileWriter + }; + }); + it('should emit a type alias for a scalar extending String', () => { + let mockScalarDeclaration = sinon.createStubInstance(ScalarDeclaration); + mockScalarDeclaration.isScalarDeclaration.returns(true); + mockScalarDeclaration.getName.returns('EmailAddress'); + mockScalarDeclaration.getType.returns('String'); + + typescriptVisitor.visitScalarDeclaration(mockScalarDeclaration, param); + + param.fileWriter.writeLine.calledOnce.should.be.ok; + param.fileWriter.writeLine.withArgs(0, 'export type EmailAddress = string;\n').calledOnce.should.be.ok; + }); + + it('should emit a type alias for a scalar extending DateTime', () => { + let mockScalarDeclaration = sinon.createStubInstance(ScalarDeclaration); + mockScalarDeclaration.isScalarDeclaration.returns(true); + mockScalarDeclaration.getName.returns('RegistrationDate'); + mockScalarDeclaration.getType.returns('DateTime'); + + typescriptVisitor.visitScalarDeclaration(mockScalarDeclaration, param); + + param.fileWriter.writeLine.calledOnce.should.be.ok; + param.fileWriter.writeLine.withArgs(0, 'export type RegistrationDate = Date;\n').calledOnce.should.be.ok; + }); + }); + + describe('visitScalarField', () => { + let param; + beforeEach(() => { + param = { + fileWriter: mockFileWriter + }; + }); + it('should write a field using the scalar type name', () => { + let field = { + getName: () => 'email', + getType: () => 'EmailAddress', + isArray: () => false, + isOptional: () => false + }; + + typescriptVisitor.visitScalarField(field, param); + + param.fileWriter.writeLine.withArgs(1, 'email: EmailAddress;').calledOnce.should.be.ok; + }); + + it('should handle optional scalar fields', () => { + let field = { + getName: () => 'email', + getType: () => 'EmailAddress', + isArray: () => false, + isOptional: () => true + }; + + typescriptVisitor.visitScalarField(field, param); + + param.fileWriter.writeLine.withArgs(1, 'email?: EmailAddress;').calledOnce.should.be.ok; + }); + + it('should handle array scalar fields', () => { + let field = { + getName: () => 'emails', + getType: () => 'EmailAddress', + isArray: () => true, + isOptional: () => false + }; + + typescriptVisitor.visitScalarField(field, param); + + param.fileWriter.writeLine.withArgs(1, 'emails: EmailAddress[];').calledOnce.should.be.ok; + }); + }); + describe('visitClassDeclaration', () => { let param; beforeEach(() => { @@ -859,6 +939,11 @@ describe('TypescriptVisitor', function () { }); let mockMapDeclaration = sinon.createStubInstance(MapDeclaration); let mockMapKeyType = sinon.createStubInstance(MapKeyType); + const mockModelFile = sinon.createStubInstance(ModelFile); + const mockValueDecl = sinon.createStubInstance(ClassDeclaration); + mockValueDecl.isEnum.returns(false); + mockModelFile.getType.returns(mockValueDecl); + mockMapDeclaration.getModelFile.returns(mockModelFile); const getKeyType = sinon.stub(); const getValueType = sinon.stub(); @@ -887,6 +972,11 @@ describe('TypescriptVisitor', function () { }); let mockMapDeclaration = sinon.createStubInstance(MapDeclaration); let mockMapKeyType = sinon.createStubInstance(MapKeyType); + const mockModelFile = sinon.createStubInstance(ModelFile); + const mockValueDecl = sinon.createStubInstance(ClassDeclaration); + mockValueDecl.isEnum.returns(false); + mockModelFile.getType.returns(mockValueDecl); + mockMapDeclaration.getModelFile.returns(mockModelFile); const getKeyType = sinon.stub(); const getValueType = sinon.stub(); @@ -914,6 +1004,11 @@ describe('TypescriptVisitor', function () { }); let mockMapDeclaration = sinon.createStubInstance(MapDeclaration); + const mockModelFile = sinon.createStubInstance(ModelFile); + const mockValueDecl = sinon.createStubInstance(ClassDeclaration); + mockValueDecl.isEnum.returns(false); + mockModelFile.getType.returns(mockValueDecl); + mockMapDeclaration.getModelFile.returns(mockModelFile); const getKeyType = sinon.stub(); const getValueType = sinon.stub(); @@ -930,6 +1025,37 @@ describe('TypescriptVisitor', function () { param.fileWriter.writeLine.withArgs(0, 'export type Map1 = Map;\n').calledOnce.should.be.ok; }); + it('should write a line with the name, key and value of the map where value is an enum', () => { + let param = { + fileWriter: mockFileWriter + }; + sandbox.restore(); + sandbox.stub(ModelUtil, 'isScalar').callsFake(() => { + return false; + }); + + let mockMapDeclaration = sinon.createStubInstance(MapDeclaration); + const mockModelFile = sinon.createStubInstance(ModelFile); + const mockEnumDecl = sinon.createStubInstance(EnumDeclaration); + mockEnumDecl.isEnum.returns(true); + mockModelFile.getType.returns(mockEnumDecl); + mockMapDeclaration.getModelFile.returns(mockModelFile); + + const getKeyType = sinon.stub(); + const getValueType = sinon.stub(); + + getKeyType.returns('String'); + getValueType.returns('TagSource'); + mockMapDeclaration.getName.returns('TagSourceMap'); + mockMapDeclaration.isMapDeclaration.returns(true); + mockMapDeclaration.getKey.returns({ getType: getKeyType }); + mockMapDeclaration.getValue.returns({ getType: getValueType }); + + typescriptVisitor.visitMapDeclaration(mockMapDeclaration, param); + + param.fileWriter.writeLine.withArgs(0, 'export type TagSourceMap = Map;\n').calledOnce.should.be.ok; + }); + it('should write a line with the name, key and value of the map ', () => { let param = { fileWriter: mockFileWriter @@ -960,7 +1086,7 @@ describe('TypescriptVisitor', function () { typescriptVisitor.visitMapDeclaration(mockMapDeclaration, param); - param.fileWriter.writeLine.withArgs(0, 'export type Map1 = Map;\n').calledOnce.should.be.ok; + param.fileWriter.writeLine.withArgs(0, 'export type Map1 = Map;\n').calledOnce.should.be.ok; }); it('should write a line with the name, key and value of the map ', () => { @@ -993,7 +1119,7 @@ describe('TypescriptVisitor', function () { typescriptVisitor.visitMapDeclaration(mockMapDeclaration, param); - param.fileWriter.writeLine.withArgs(0, 'export type Map1 = Map;\n').calledOnce.should.be.ok; + param.fileWriter.writeLine.withArgs(0, 'export type Map1 = Map;\n').calledOnce.should.be.ok; }); }); From 5c7b530f2fb3098459912fea12b65783324630ca Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Wed, 20 May 2026 01:55:06 +0100 Subject: [PATCH 2/5] fix(typescript): scope union aliases to same-namespace with >1 subclass Signed-off-by: muhammed-abdulkadir --- .../fromcto/typescript/typescriptvisitor.js | 47 ++++------ test/codegen/__snapshots__/codegen.js.snap | 75 ---------------- .../fromcto/typescript/typescriptvisitor.js | 89 +++++++++++++++++-- 3 files changed, 98 insertions(+), 113 deletions(-) diff --git a/lib/codegen/fromcto/typescript/typescriptvisitor.js b/lib/codegen/fromcto/typescript/typescriptvisitor.js index 749a028..5f0d445 100644 --- a/lib/codegen/fromcto/typescript/typescriptvisitor.js +++ b/lib/codegen/fromcto/typescript/typescriptvisitor.js @@ -127,26 +127,6 @@ class TypescriptVisitor { } }); - const subclasses = classDeclaration.getDirectSubclasses(); - if (subclasses && subclasses.length > 0) { - parameters.fileWriter.writeLine(0, '\n// Warning: Beware of circular dependencies when modifying these imports'); - - // Group subclasses by namespace - const namespaceBuckets = {}; - subclasses.map(subclass => { - const bucket = namespaceBuckets[subclass.getNamespace()]; - if (bucket){ - bucket.push(subclass); - } else { - namespaceBuckets[subclass.getNamespace()] = [subclass]; - } - }); - Object.entries(namespaceBuckets) - .filter(([namespace]) => namespace !== modelFile.getNamespace()) // Skip own namespace - .map(([namespace, bucket]) => { - parameters.fileWriter.writeLine(0, `import type {\n\t${bucket.map(subclass => subclass.isEnum() ? subclass.getName() : `I${subclass.getName()}`).join(',\n\t') }\n} from './${namespace}';`); - }); - } }); @@ -225,11 +205,15 @@ class TypescriptVisitor { parameters.fileWriter.writeLine(0, '}\n'); - // If there exists direct subclasses for this declaration then generate a union for it + // Generate a union alias only when there are multiple same-namespace subclasses const subclasses = classDeclaration.getDirectSubclasses(); - if (subclasses && subclasses.length > 0) { - parameters.fileWriter.writeLine(0, 'export type ' + classDeclaration.getName() + - 'Union = ' + subclasses.filter(declaration => !declaration.isEnum()).map(subclass => `I${subclass.getName()}`).join(' | \n') + ';\n'); + if (subclasses) { + const sameNsSubclasses = subclasses + .filter(sc => !sc.isEnum() && sc.getNamespace() === classDeclaration.getNamespace()); + if (sameNsSubclasses.length > 1) { + parameters.fileWriter.writeLine(0, 'export type ' + classDeclaration.getName() + + 'Union = ' + sameNsSubclasses.map(subclass => `I${subclass.getName()}`).join(' | \n') + ';\n'); + } } return null; } @@ -281,12 +265,17 @@ class TypescriptVisitor { let tsType = this.toTsType(field.getType(), !isEnumRef && !hasUnion && !isMapRef, hasUnion); - // If there exists direct subclasses for this field's declaration then use the union type instead + // Use the union type only when there are multiple same-namespace subclasses if (!!parameters.flattenSubclassesToUnion & !field.isPrimitive()) { - const subclasses = field.getParent().getModelFile().getModelManager().getType(field.getFullyQualifiedTypeName()).getDirectSubclasses(); - if (subclasses && subclasses.length > 0) { - const useUnion = !(isEnumRef || isMapRef); - tsType = this.toTsType(field.getType(), !useUnion, useUnion); + const fieldDecl = field.getParent().getModelFile().getModelManager().getType(field.getFullyQualifiedTypeName()); + const subclasses = fieldDecl.getDirectSubclasses(); + if (subclasses) { + const sameNsSubclasses = subclasses + .filter(sc => !sc.isEnum() && sc.getNamespace() === fieldDecl.getNamespace()); + if (sameNsSubclasses.length > 1) { + const useUnion = !(isEnumRef || isMapRef); + tsType = this.toTsType(field.getType(), !useUnion, useUnion); + } } } diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index 809a952..9544aea 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -6753,19 +6753,12 @@ exports[`codegen #formats check we can convert all formats from namespace versio // Generated code for namespace: concerto.decorator@1.0.0 // imports - -// Warning: Beware of circular dependencies when modifying these imports -import type { - Ipii -} from './org.acme.hr@1.0.0'; import {IConcept} from './concerto@1.0.0'; // interfaces export interface IDecorator extends IConcept { } -export type DecoratorUnion = Ipii; - export interface IDotNetNamespace extends IDecorator { namespace: string; } @@ -6782,77 +6775,27 @@ exports[`codegen #formats check we can convert all formats from namespace versio // imports -// Warning: Beware of circular dependencies when modifying these imports -import type { - ICategory, - State, - TShirtSizeType, - IAddress, - Level -} from './org.acme.hr.base@1.0.0'; -import type { - ICategory, - IInfo, - ICompany, - Department, - LaptopMake -} from './org.acme.hr@1.0.0'; - -// Warning: Beware of circular dependencies when modifying these imports -import type { - IEquipment -} from './org.acme.hr@1.0.0'; - -// Warning: Beware of circular dependencies when modifying these imports -import type { - IPerson -} from './org.acme.hr@1.0.0'; - -// Warning: Beware of circular dependencies when modifying these imports -import type { - IChangeOfAddress -} from './org.acme.hr@1.0.0'; - -// Warning: Beware of circular dependencies when modifying these imports -import type { - ICompanyEvent -} from './org.acme.hr@1.0.0'; - // interfaces export interface IConcept { $class: string; } -export type ConceptUnion = ICategory | -IAddress | -ICategory | -IInfo | -ICompany; - export interface IAsset extends IConcept { $identifier: string; } -export type AssetUnion = IEquipment; - export interface IParticipant extends IConcept { $identifier: string; } -export type ParticipantUnion = IPerson; - export interface ITransaction extends IConcept { $timestamp: Date; } -export type TransactionUnion = IChangeOfAddress; - export interface IEvent extends IConcept { $timestamp: Date; } -export type EventUnion = ICompanyEvent; - ", } `; @@ -6864,16 +6807,12 @@ exports[`codegen #formats check we can convert all formats from namespace versio // Generated code for namespace: org.acme.hr.base@1.0.0 // imports - -// Warning: Beware of circular dependencies when modifying these imports import {IConcept} from './concerto@1.0.0'; // interfaces export interface ICategory extends IConcept { } -export type CategoryUnion = IGeneralCategory; - export interface IGeneralCategory extends ICategory { } @@ -6920,14 +6859,6 @@ exports[`codegen #formats check we can convert all formats from namespace versio // Generated code for namespace: org.acme.hr@1.0.0 // imports - -// Warning: Beware of circular dependencies when modifying these imports - -// Warning: Beware of circular dependencies when modifying these imports - -// Warning: Beware of circular dependencies when modifying these imports - -// Warning: Beware of circular dependencies when modifying these imports import {IAddress,IEmployeeTShirtSizes,SSN} from './org.acme.hr.base@1.0.0'; import {IDecorator} from './concerto.decorator@1.0.0'; import {IConcept,IAsset,IParticipant,IEvent,ITransaction} from './concerto@1.0.0'; @@ -6980,8 +6911,6 @@ export interface IEquipment extends IAsset { serialNumber: string; } -export type EquipmentUnion = ILaptop; - export enum LaptopMake { Apple = 'Apple', Microsoft = 'Microsoft', @@ -7017,8 +6946,6 @@ export interface IEmployee extends IPerson { manager?: IManager; } -export type EmployeeUnion = IManager; - export interface IContractor extends IPerson { company: ICompany; manager?: IManager; @@ -7031,8 +6958,6 @@ export interface IManager extends IEmployee { export interface ICompanyEvent extends IEvent { } -export type CompanyEventUnion = IOnboarded; - export interface IOnboarded extends ICompanyEvent { employee: IEmployee; } diff --git a/test/codegen/fromcto/typescript/typescriptvisitor.js b/test/codegen/fromcto/typescript/typescriptvisitor.js index 6eca3a0..84eda8f 100644 --- a/test/codegen/fromcto/typescript/typescriptvisitor.js +++ b/test/codegen/fromcto/typescript/typescriptvisitor.js @@ -352,7 +352,7 @@ describe('TypescriptVisitor', function () { acceptSpy.withArgs(typescriptVisitor, param).calledTwice.should.be.ok; }); - it('should write lines for the imports of direct subclasses that are not in the same namespace', () => { + it('should not write cross-namespace subclass imports since unions are scoped to same namespace', () => { let acceptSpy = sinon.spy(); let mockSubclassDeclaration1 = sinon.createStubInstance(ClassDeclaration); @@ -397,13 +397,11 @@ describe('TypescriptVisitor', function () { typescriptVisitor.visitModelFile(mockModelFile, param); param.fileWriter.openFile.withArgs('org.acme.ts').calledOnce.should.be.ok; - param.fileWriter.writeLine.callCount.should.deep.equal(6); + param.fileWriter.writeLine.callCount.should.deep.equal(4); param.fileWriter.writeLine.getCall(0).args.should.deep.equal([0, '/* eslint-disable @typescript-eslint/no-empty-interface */']); param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, '// Generated code for namespace: org.acme']); param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, '\n// imports']); - param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, '\n// Warning: Beware of circular dependencies when modifying these imports']); - param.fileWriter.writeLine.getCall(4).args.should.deep.equal([0, 'import type {\n\tIImportedDirectSubclass,\n\tIImportedDirectSubclass2\n} from \'./org.acme.subclasses\';']); - param.fileWriter.writeLine.getCall(5).args.should.deep.equal([0, '\n// interfaces']); + param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, '\n// interfaces']); param.fileWriter.closeFile.calledOnce.should.be.ok; acceptSpy.withArgs(typescriptVisitor, param).calledOnce.should.be.ok; @@ -631,7 +629,7 @@ describe('TypescriptVisitor', function () { param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, '}\n']); }); - it('should create a union given a class that has dependencies but no super class', () => { + it('should not create a union given a class with only one same-namespace subclass', () => { let acceptSpy = sinon.spy(); let mockChildClassDeclaration = sinon.createStubInstance(ClassDeclaration); @@ -643,6 +641,7 @@ describe('TypescriptVisitor', function () { accept: acceptSpy }]); mockChildClassDeclaration.getName.returns('Child'); + mockChildClassDeclaration.getNamespace.returns('org.acme'); mockChildClassDeclaration.isAbstract.returns(false); mockChildClassDeclaration.getSuperType.returns('Parent'); @@ -655,17 +654,52 @@ describe('TypescriptVisitor', function () { accept: acceptSpy }]); mockClassDeclaration.getName.returns('Parent'); + mockClassDeclaration.getNamespace.returns('org.acme'); mockClassDeclaration.isAbstract.returns(true); mockClassDeclaration.getSuperType.returns(null); mockClassDeclaration.getDirectSubclasses.returns([mockChildClassDeclaration]); typescriptVisitor.visitClassDeclaration(mockClassDeclaration, param); + param.fileWriter.writeLine.callCount.should.deep.equal(3); + param.fileWriter.writeLine.getCall(0).args.should.deep.equal([0, 'export interface IParent {']); + param.fileWriter.writeLine.getCall(1).args.should.deep.equal([1, '$class: string;']); + param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, '}\n']); + }); + + it('should create a union given a class with multiple same-namespace subclasses', () => { + let acceptSpy = sinon.spy(); + + let mockChildClassDeclaration1 = sinon.createStubInstance(ClassDeclaration); + mockChildClassDeclaration1.isClassDeclaration.returns(true); + mockChildClassDeclaration1.getName.returns('Child1'); + mockChildClassDeclaration1.getNamespace.returns('org.acme'); + mockChildClassDeclaration1.isAbstract.returns(false); + + let mockChildClassDeclaration2 = sinon.createStubInstance(ClassDeclaration); + mockChildClassDeclaration2.isClassDeclaration.returns(true); + mockChildClassDeclaration2.getName.returns('Child2'); + mockChildClassDeclaration2.getNamespace.returns('org.acme'); + mockChildClassDeclaration2.isAbstract.returns(false); + + let mockClassDeclaration = sinon.createStubInstance(ClassDeclaration); + mockClassDeclaration.isClassDeclaration.returns(true); + mockClassDeclaration.getOwnProperties.returns([{ + accept: acceptSpy + }]); + mockClassDeclaration.getName.returns('Parent'); + mockClassDeclaration.getNamespace.returns('org.acme'); + mockClassDeclaration.isAbstract.returns(true); + mockClassDeclaration.getSuperType.returns(null); + mockClassDeclaration.getDirectSubclasses.returns([mockChildClassDeclaration1, mockChildClassDeclaration2]); + + typescriptVisitor.visitClassDeclaration(mockClassDeclaration, param); + param.fileWriter.writeLine.callCount.should.deep.equal(4); param.fileWriter.writeLine.getCall(0).args.should.deep.equal([0, 'export interface IParent {']); param.fileWriter.writeLine.getCall(1).args.should.deep.equal([1, '$class: string;']); param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, '}\n']); - param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, 'export type ParentUnion = IChild;\n']); + param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, 'export type ParentUnion = IChild1 | \nIChild2;\n']); }); it('should not create a union if a class has no sub-classes', () => { @@ -832,7 +866,7 @@ describe('TypescriptVisitor', function () { param.fileWriter.writeLine.withArgs(1, 'literalTest = EnumType.MyEnumValue;').calledOnce.should.be.ok; }); - it('should write a line for field name using a union type when the flattenSubclassesToUnion parameter is set', () => { + it('should write a line for field name using a union type when the flattenSubclassesToUnion parameter is set and there are multiple same-namespace subclasses', () => { const mockField = sinon.createStubInstance(Field); mockField.isPrimitive.returns(false); mockField.getName.returns('flattenSubclassesTest'); @@ -842,7 +876,16 @@ describe('TypescriptVisitor', function () { const mockModelManager = sinon.createStubInstance(ModelManager); const mockModelFile = sinon.createStubInstance(ModelFile); const mockClassDeclaration = sinon.createStubInstance(ClassDeclaration); - mockClassDeclaration.getDirectSubclasses.returns(['blah']); // Not valid, but sufficient for this test + + const mockSubclass1 = sinon.createStubInstance(ClassDeclaration); + mockSubclass1.getNamespace.returns('org.acme'); + mockSubclass1.isEnum.returns(false); + const mockSubclass2 = sinon.createStubInstance(ClassDeclaration); + mockSubclass2.getNamespace.returns('org.acme'); + mockSubclass2.isEnum.returns(false); + + mockClassDeclaration.getDirectSubclasses.returns([mockSubclass1, mockSubclass2]); + mockClassDeclaration.getNamespace.returns('org.acme'); mockModelManager.getType.returns(mockClassDeclaration); mockClassDeclaration.isEnum.returns(false); @@ -853,6 +896,34 @@ describe('TypescriptVisitor', function () { param.fileWriter.writeLine.withArgs(1, 'flattenSubclassesTest: AnimalUnion;').calledOnce.should.be.ok; }); + + it('should not use union type when flattenSubclassesToUnion is set but there is only one same-namespace subclass', () => { + const mockField = sinon.createStubInstance(Field); + mockField.isPrimitive.returns(false); + mockField.getName.returns('singleSubclassTest'); + mockField.getType.returns('Animal'); + mockField.getDecorators.returns([]); + + const mockModelManager = sinon.createStubInstance(ModelManager); + const mockModelFile = sinon.createStubInstance(ModelFile); + const mockClassDeclaration = sinon.createStubInstance(ClassDeclaration); + + const mockSubclass1 = sinon.createStubInstance(ClassDeclaration); + mockSubclass1.getNamespace.returns('org.acme'); + mockSubclass1.isEnum.returns(false); + + mockClassDeclaration.getDirectSubclasses.returns([mockSubclass1]); + mockClassDeclaration.getNamespace.returns('org.acme'); + + mockModelManager.getType.returns(mockClassDeclaration); + mockClassDeclaration.isEnum.returns(false); + mockModelFile.getModelManager.returns(mockModelManager); + mockClassDeclaration.getModelFile.returns(mockModelFile); + mockField.getParent.returns(mockClassDeclaration); + typescriptVisitor.visitField(mockField, { ...param, flattenSubclassesToUnion: true }); + + param.fileWriter.writeLine.withArgs(1, 'singleSubclassTest: IAnimal;').calledOnce.should.be.ok; + }); }); describe('visitEnumValueDeclaration', () => { From bee210ae733a7c24928d3b18472bdf9cf3b35e1b Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Wed, 20 May 2026 14:29:46 +0100 Subject: [PATCH 3/5] chore(*): merge main Signed-off-by: muhammed-abdulkadir --- .../fromcto/typescript/typescriptvisitor.js | 27 ------------------- test/codegen/__snapshots__/codegen.js.snap | 9 ------- 2 files changed, 36 deletions(-) diff --git a/lib/codegen/fromcto/typescript/typescriptvisitor.js b/lib/codegen/fromcto/typescript/typescriptvisitor.js index b0644a3..f0db334 100644 --- a/lib/codegen/fromcto/typescript/typescriptvisitor.js +++ b/lib/codegen/fromcto/typescript/typescriptvisitor.js @@ -130,12 +130,6 @@ class TypescriptVisitor { const valueDecl = declaration.getModelFile().getModelManager().getType(fqn); addImport(typeNamespace, valueDecl?.isEnum?.() ? valueType : `I${valueType}`); } - properties.get(typeNamespace).add( - property.isTypeEnum?.() || property.isTypeScalar?.() ? typeName : `I${typeName}`); - } - }); - - } } else if (!declaration.isEnum()) { if (declaration.getSuperType()) { @@ -152,27 +146,6 @@ class TypescriptVisitor { property.isTypeEnum?.() || property.isTypeScalar?.() ? typeName : `I${typeName}`); } }); - - const subclasses = declaration.getDirectSubclasses(); - if (subclasses && subclasses.length > 0) { - parameters.fileWriter.writeLine(0, '\n// Warning: Beware of circular dependencies when modifying these imports'); - - // Group subclasses by namespace - const namespaceBuckets = {}; - subclasses.map(subclass => { - const bucket = namespaceBuckets[subclass.getNamespace()]; - if (bucket){ - bucket.push(subclass); - } else { - namespaceBuckets[subclass.getNamespace()] = [subclass]; - } - }); - Object.entries(namespaceBuckets) - .filter(([namespace]) => namespace !== modelFile.getNamespace()) // Skip own namespace - .map(([namespace, bucket]) => { - parameters.fileWriter.writeLine(0, `import type {\n\t${bucket.map(subclass => subclass.isEnum() ? subclass.getName() : `I${subclass.getName()}`).join(',\n\t') }\n} from './${namespace}';`); - }); - } } }); diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index d2ee953..b0aefe7 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -6859,15 +6859,6 @@ exports[`codegen #formats check we can convert all formats from namespace versio // Generated code for namespace: org.acme.hr@1.0.0 // imports -import {IAddress,IEmployeeTShirtSizes,SSN} from './org.acme.hr.base@1.0.0'; - -// Warning: Beware of circular dependencies when modifying these imports - -// Warning: Beware of circular dependencies when modifying these imports - -// Warning: Beware of circular dependencies when modifying these imports - -// Warning: Beware of circular dependencies when modifying these imports import {Time,SSN,IAddress,IEmployeeTShirtSizes} from './org.acme.hr.base@1.0.0'; import {IDecorator} from './concerto.decorator@1.0.0'; import {IConcept,IAsset,IParticipant,IEvent,ITransaction} from './concerto@1.0.0'; From 120717c5e9c9a09684b394747ef0da05db0f4397 Mon Sep 17 00:00:00 2001 From: Muhammed Abdulkadir <40035796+muhabdulkadir@users.noreply.github.com> Date: Wed, 20 May 2026 14:47:45 +0100 Subject: [PATCH 4/5] fix(*): switch bitwise operator to logical operator Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Muhammed Abdulkadir <40035796+muhabdulkadir@users.noreply.github.com> --- lib/codegen/fromcto/typescript/typescriptvisitor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/codegen/fromcto/typescript/typescriptvisitor.js b/lib/codegen/fromcto/typescript/typescriptvisitor.js index f0db334..7888549 100644 --- a/lib/codegen/fromcto/typescript/typescriptvisitor.js +++ b/lib/codegen/fromcto/typescript/typescriptvisitor.js @@ -285,7 +285,7 @@ class TypescriptVisitor { let tsType = this.toTsType(field.getType(), !isEnumRef && !hasUnion && !isMapRef, hasUnion); // Use the union type only when there are multiple same-namespace subclasses - if (!!parameters.flattenSubclassesToUnion & !field.isPrimitive()) { + if (!!parameters.flattenSubclassesToUnion && !field.isPrimitive()) { const fieldDecl = field.getParent().getModelFile().getModelManager().getType(field.getFullyQualifiedTypeName()); const subclasses = fieldDecl.getDirectSubclasses(); if (subclasses) { From 8fb29f5f780e6cb8e4cbd0a238b1e972038fac11 Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Thu, 21 May 2026 13:54:54 +0100 Subject: [PATCH 5/5] chore(*): import unions for type reference Signed-off-by: muhammed-abdulkadir --- lib/codegen/fromcto/typescript/typescriptvisitor.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/codegen/fromcto/typescript/typescriptvisitor.js b/lib/codegen/fromcto/typescript/typescriptvisitor.js index 7888549..e98a7f0 100644 --- a/lib/codegen/fromcto/typescript/typescriptvisitor.js +++ b/lib/codegen/fromcto/typescript/typescriptvisitor.js @@ -144,6 +144,19 @@ class TypescriptVisitor { const typeName = ModelUtil.getShortName(property.getFullyQualifiedTypeName()); addImport(typeNamespace, property.isTypeEnum?.() || property.isTypeScalar?.() ? typeName : `I${typeName}`); + + // When flattenSubclassesToUnion is set, also import the union type + if (parameters.flattenSubclassesToUnion && !property.isTypeEnum?.() && !property.isTypeScalar?.()) { + const propDecl = modelFile.getModelManager().getType(property.getFullyQualifiedTypeName()); + const subclasses = propDecl?.getDirectSubclasses?.(); + if (subclasses) { + const sameNsSubs = subclasses.filter(sc => + !sc.isEnum() && sc.getNamespace() === propDecl.getNamespace()); + if (sameNsSubs.length > 1) { + addImport(typeNamespace, `${typeName}Union`); + } + } + } } }); }