Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0e6998f
Create OneOfInterface for OneOfModels
ksvirkou-hubspot Oct 8, 2025
451f488
Typescript OneOf: Find matching type if OneClass has no discriminator
ksvirkou-hubspot Oct 13, 2025
74351c7
Typescript: Test oneOf
ksvirkou-hubspot Oct 21, 2025
fe10060
cs fixes
ksvirkou-hubspot Oct 22, 2025
234643d
Update samples
ksvirkou-hubspot Oct 22, 2025
e03add7
Rewrite OneOfClass to standalone helper TypeMatcher
ksvirkou-hubspot Oct 24, 2025
49e768a
RM unused OneOfClasses
ksvirkou-hubspot Oct 24, 2025
97a775d
RM unused OneOfClasses
ksvirkou-hubspot Oct 24, 2025
eee422e
Improve oneOf type categorization in ExtendedCodegenModel
ksvirkou-hubspot Oct 27, 2025
81acbbc
Improve type safety and error reporting for oneOf schema resolution
ksvirkou-hubspot Oct 29, 2025
8e7a128
Restructured model.mustache
ksvirkou-hubspot Oct 29, 2025
de4e09e
Update instanceOfType metthod
ksvirkou-hubspot Oct 30, 2025
7da0c2a
Merge master
ksvirkou-hubspot Mar 30, 2026
ca525a9
Add oneOf tests for TypeScript generator to CI
ksvirkou-hubspot Mar 30, 2026
0bd35fc
Regenerate samples
ksvirkou-hubspot Mar 31, 2026
a117fcc
Regenerate samples
ksvirkou-hubspot Mar 31, 2026
c590359
Regenerate samples
ksvirkou-hubspot Mar 31, 2026
ded868c
Typescript test OneOf: added pow.xml
ksvirkou-hubspot Apr 3, 2026
c806d60
Merge pull request #3 from OpenAPITools/master
ksvirkou-hubspot Apr 3, 2026
1a4b46d
Typescript test OneOf: added tests to CI
ksvirkou-hubspot Apr 3, 2026
8fd54d2
Typescript test OneOf: fix CI errors
ksvirkou-hubspot Apr 3, 2026
6be8f0c
Typescript test OneOf: fix CI errors
ksvirkou-hubspot Apr 3, 2026
69954aa
Typescript test OneOf: fix CI errors
ksvirkou-hubspot Apr 3, 2026
23a2dd7
Typescript test OneOf: fix CI errors
ksvirkou-hubspot Apr 3, 2026
3bfe9cc
Typescript test OneOf: fix CI errors
ksvirkou-hubspot Apr 3, 2026
14d1edc
Typescript test OneOf: fix CI errors
ksvirkou-hubspot Apr 3, 2026
d4984c0
Typescript test OneOf: Added module to build tsconfig
ksvirkou-hubspot Apr 3, 2026
b04a5ed
Typescript test OneOf: added error to tests
ksvirkou-hubspot Apr 3, 2026
f8c65fb
rm error
ksvirkou-hubspot Apr 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/samples-typescript-client.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ on:
# comment out due to build failure
#- samples/openapi3/client/petstore/typescript/tests/browser/**
#- samples/openapi3/client/petstore/typescript/builds/nullable-enum/**
- samples/openapi3/client/petstore/typescript/builds/one-of/**
- samples/openapi3/client/petstore/typescript/tests/one-of/**
- samples/client/petstore/typescript-fetch/builds/default/**
- samples/client/petstore/typescript-fetch/builds/es6-target/**
- samples/client/petstore/typescript-fetch/builds/with-npm-version/**
Expand Down Expand Up @@ -68,6 +70,8 @@ on:
- samples/openapi3/client/petstore/typescript/builds/browser/**
#- samples/openapi3/client/petstore/typescript/tests/browser/**
#- samples/openapi3/client/petstore/typescript/builds/nullable-enum/**
- samples/openapi3/client/petstore/typescript/builds/one-of/**
- samples/openapi3/client/petstore/typescript/tests/one-of/**
- samples/client/petstore/typescript-fetch/builds/default/**
- samples/client/petstore/typescript-fetch/builds/es6-target/**
- samples/client/petstore/typescript-fetch/builds/with-npm-version/**
Expand Down Expand Up @@ -119,6 +123,8 @@ jobs:
- samples/openapi3/client/petstore/typescript/builds/browser/
#- samples/openapi3/client/petstore/typescript/tests/browser/
#- samples/openapi3/client/petstore/typescript/builds/nullable-enum/
- samples/openapi3/client/petstore/typescript/builds/one-of/
- samples/openapi3/client/petstore/typescript/tests/one-of/
- samples/client/petstore/typescript-fetch/builds/default/
- samples/client/petstore/typescript-fetch/builds/es6-target/
- samples/client/petstore/typescript-fetch/builds/with-npm-version/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER;
import static org.openapitools.codegen.utils.OnceLogger.once;
Expand Down Expand Up @@ -183,6 +184,9 @@ public TypeScriptClientCodegen() {
// models
setModelPackage("models");
supportingFiles.add(new SupportingFile("model" + File.separator + "ObjectSerializer.mustache", "models", "ObjectSerializer.ts"));
supportingFiles.add(new SupportingFile("model" + File.separator + "TypeMatcher.mustache", "models", "TypeMatcher.ts"));
supportingFiles.add(new SupportingFile("model" + File.separator + "ModelTypes.mustache", "models", "ModelTypes.ts"));

modelTemplateFiles.put("model" + File.separator + "model.mustache", ".ts");

// api
Expand Down Expand Up @@ -369,12 +373,22 @@ private String getNameWithEnumPropertyNaming(String name) {
}
}

@Override
public CodegenModel fromModel(String name, Schema schema) {
CodegenModel codegenModel = super.fromModel(name, schema);
return new ExtendedCodegenModel(codegenModel);
}

@Override
public ModelsMap postProcessModels(ModelsMap objs) {
// process enum in models
List<ModelMap> models = postProcessModelsEnum(objs).getModels();
for (ModelMap mo : models) {
CodegenModel cm = mo.getModel();
CodegenModel model = mo.getModel();
// Convert to ExtendedCodegenModel if it's not already one (e.g., in tests)
ExtendedCodegenModel cm = model instanceof ExtendedCodegenModel
? (ExtendedCodegenModel) model
: new ExtendedCodegenModel(model);
cm.imports = new TreeSet<>(cm.imports);
// name enum with model name, e.g. StatusEnum => Pet.StatusEnum
for (CodegenProperty var : cm.vars) {
Expand All @@ -390,15 +404,37 @@ public ModelsMap postProcessModels(ModelsMap objs) {
}
}
}

List<CodegenProperty> oneOfsList = Optional.ofNullable(cm.getComposedSchemas())
.map(CodegenComposedSchemas::getOneOf)
.orElse(Collections.emptyList());

// create a set of any non-primitive, non-array types used in the oneOf schemas which will
// need to be imported.
cm.oneOfModels = oneOfsList.stream()
.filter(cp -> !cp.getIsPrimitiveType() && !cp.getIsArray())
.map(CodegenProperty::getBaseType)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(TreeSet::new));

// create a set of any complex, inner types used by arrays in the oneOf schema (e.g. if
// the oneOf uses Array<Foo>, Foo needs to be imported).
cm.oneOfArrays = oneOfsList.stream()
.filter(CodegenProperty::getIsArray)
.map(CodegenProperty::getComplexType)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(TreeSet::new));

// create a set of primitive types used in the oneOf schemas for the deserialization process.
cm.oneOfPrimitives = oneOfsList.stream()
.filter(CodegenProperty::getIsPrimitiveType)
.collect(Collectors.toCollection(HashSet::new));

if (!cm.oneOf.isEmpty()) {
// For oneOfs only import $refs within the oneOf
TreeSet<String> oneOfRefs = new TreeSet<>();
for (String im : cm.imports) {
if (cm.oneOf.contains(im)) {
oneOfRefs.add(im);
}
}
cm.imports = oneOfRefs;
cm.imports = cm.imports.stream()
.filter(im -> cm.oneOfModels.contains(im) || cm.oneOfArrays.contains(im))
.collect(Collectors.toCollection(TreeSet::new));
}
}
for (ModelMap mo : models) {
Expand Down Expand Up @@ -1135,4 +1171,111 @@ protected void addImport(Set<String> importsToBeAddedTo, String type) {
protected String[] splitComposedTypes(String type) {
return type.replace(" ", "").split("[|&<>]");
}

public class ExtendedCodegenModel extends CodegenModel {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we somehow do without an extended CodegenModel class? everytime the base class is updated (e.g. new properties are added), this would need to be updated here as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could consider moving our additional properties to the base class itself.

I've extended the CodegenModel class because I need to add new properties specific to oneOf constructions. As far as I understand, extending the base class is a common approach — for example, the typescript-fetch generator uses the same pattern with its own ExtendedCodegenModel class.

// oneOfModels contains a list of non-primitive, non-array types referenced in oneOf schemas that need to be
// imported.
// oneOfArrays contains a list of complex inner types used in arrays within oneOf schemas (e.g. if
// oneOf uses Array<Foo>, Foo needs to be imported).
// oneOfPrimitives contains a list of primitive types used in oneOf schemas for the deserialization process.
@Getter @Setter
public Set<String> oneOfModels = new TreeSet<>();
@Getter @Setter
public Set<String> oneOfArrays = new TreeSet<>();
@Getter @Setter
public Set<CodegenProperty> oneOfPrimitives = new HashSet<>();

public ExtendedCodegenModel(CodegenModel cm) {
super();

this.parent = cm.parent;
this.parentSchema = cm.parentSchema;
this.interfaces = cm.interfaces;
this.allParents = cm.allParents;
this.parentModel = cm.parentModel;
this.interfaceModels = cm.interfaceModels;
this.children = cm.children;
this.anyOf = cm.anyOf;
this.oneOf = cm.oneOf;
this.allOf = cm.allOf;
this.permits = cm.permits;
this.name = cm.name;
this.schemaName = cm.schemaName;
this.classname = cm.classname;
this.title = cm.title;
this.description = cm.description;
this.classVarName = cm.classVarName;
this.modelJson = cm.modelJson;
this.dataType = cm.dataType;
this.xmlPrefix = cm.xmlPrefix;
this.xmlNamespace = cm.xmlNamespace;
this.xmlName = cm.xmlName;
this.classFilename = cm.classFilename;
this.unescapedDescription = cm.unescapedDescription;
this.defaultValue = cm.defaultValue;
this.arrayModelType = cm.arrayModelType;
this.isAlias = cm.isAlias;
this.isString = cm.isString;
this.isInteger = cm.isInteger;
this.isLong = cm.isLong;
this.isNumber = cm.isNumber;
this.isNumeric = cm.isNumeric;
this.isFloat = cm.isFloat;
this.isDouble = cm.isDouble;
this.isDate = cm.isDate;
this.isDateTime = cm.isDateTime;
this.vars = cm.vars;
this.allVars = cm.allVars;
this.requiredVars = cm.requiredVars;
this.optionalVars = cm.optionalVars;
this.hasReadOnly = cm.hasReadOnly;
this.readOnlyVars = cm.readOnlyVars;
this.readWriteVars = cm.readWriteVars;
this.parentVars = cm.parentVars;
this.parentRequiredVars = cm.parentRequiredVars;
this.nonNullableVars = cm.nonNullableVars;
this.allowableValues = cm.allowableValues;
this.mandatory = cm.mandatory;
this.allMandatory = cm.allMandatory;
this.imports = cm.imports;
this.hasVars = cm.hasVars;
this.emptyVars = cm.emptyVars;
this.hasMoreModels = cm.hasMoreModels;
this.hasEnums = cm.hasEnums;
this.isEnum = cm.isEnum;
this.hasValidation = cm.hasValidation;
this.isNullable = cm.isNullable;
this.hasRequired = cm.hasRequired;
this.hasOptional = cm.hasOptional;
this.isArray = cm.isArray;
this.hasChildren = cm.hasChildren;
this.isMap = cm.isMap;
this.isDeprecated = cm.isDeprecated;
this.hasOnlyReadOnly = cm.hasOnlyReadOnly;
this.externalDocumentation = cm.externalDocumentation;

this.vendorExtensions = cm.vendorExtensions;
this.additionalPropertiesType = cm.additionalPropertiesType;
this.isAdditionalPropertiesTrue = cm.isAdditionalPropertiesTrue;
this.setMaxProperties(cm.getMaxProperties());
this.setMinProperties(cm.getMinProperties());
this.setUniqueItems(cm.getUniqueItems());
this.setMaxItems(cm.getMaxItems());
this.setMinItems(cm.getMinItems());
this.setMaxLength(cm.getMaxLength());
this.setMinLength(cm.getMinLength());
this.setExclusiveMinimum(cm.getExclusiveMinimum());
this.setExclusiveMaximum(cm.getExclusiveMaximum());
this.setMinimum(cm.getMinimum());
this.setMaximum(cm.getMaximum());
this.setPattern(cm.getPattern());
this.setMultipleOf(cm.getMultipleOf());
this.setItems(cm.getItems());
this.setAdditionalProperties(cm.getAdditionalProperties());
this.setIsModel(cm.getIsModel());
this.setComposedSchemas(cm.getComposedSchemas());
this.setDiscriminator(cm.getDiscriminator());
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Represents a single attribute/property metadata entry in the attributeTypeMap.
* Used for validation and type matching in oneOf scenarios.
*/
export type AttributeTypeMapEntry = {
name: string;
baseName: string;
type: string;
format: string;
required: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -127,26 +127,37 @@ export class ObjectSerializer {

// Check the discriminator
let discriminatorProperty = typeMap[expectedType].discriminator;
if (discriminatorProperty == null) {
return expectedType; // the type does not have a discriminator. use it.
} else {
if (data[discriminatorProperty]) {
var discriminatorType = data[discriminatorProperty];
let mapping = typeMap[expectedType].mapping;
if (mapping != undefined && mapping[discriminatorType]) {
return mapping[discriminatorType]; // use the type given in the discriminator
} else if(typeMap[discriminatorType]) {
return discriminatorType;
} else {
return expectedType; // discriminator did not map to a type
if (discriminatorProperty == null || !data[discriminatorProperty]) {
if (this.hasFindMatchingTypeMethod(typeMap[expectedType])) {
const foundType = typeMap[expectedType].findMatchingType(data);
if (foundType == undefined) {
throw new Error("Unable to determine a unique type for the provided object: oneOf type resolution failed. The object does not match exactly one schema. Consider adding a discriminator or making schemas mutually exclusive.");
}

return foundType;
}
return expectedType; // the type does not have a discriminator and findMatchingType method. use it.
} else {
let discriminatorType = data[discriminatorProperty];
let mapping = typeMap[expectedType].mapping;
if (mapping != undefined && mapping[discriminatorType]) {
return mapping[discriminatorType]; // use the type given in the discriminator
} else if(typeMap[discriminatorType]) {
return discriminatorType;
} else {
return expectedType; // discriminator was not present (or an empty string)
throw new Error(`Discriminator property '${discriminatorProperty}' has value '${discriminatorType}' which does not map to any known type in '${expectedType}'.`);
}
}
}
}

private static hasFindMatchingTypeMethod(klass: any): boolean {
if (typeof klass.findMatchingType === 'function') {
return true;
}
return false;
}

public static serialize(data: any, type: string, format: string): any {
if (data == undefined) {
return data;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { AttributeTypeMapEntry } from '../models/ModelTypes{{importFileExtension}}';

/**
* Validates if data contains all required attributes from the attributeTypeMap.
*
* @param data - The data object to validate
* @param attributeTypeMap - Array of attribute metadata including required flag
* @returns true if all required attributes are present in data, false otherwise
*/
export function instanceOfType(data: any, attributeTypeMap: Array<AttributeTypeMapEntry>): boolean {
for (const attribute of attributeTypeMap) {
if (attribute.required && data[attribute.baseName] === undefined) {
return false;
}
}

return true;
}

/**
* Attempts to find a matching type from an array of possible types by validating
* the data against each type's attribute requirements.
*
* @param data - The data object to match
* @param types - Array of possible type constructors
* @returns The name of the matching type, or undefined if no match found
*/
export function findMatchingType(data: any, types: Array<any>): string | undefined {
for (const type of types) {
if (instanceOfType(data, type.getAttributeTypeMap())) {
return type.name;
}
}

return undefined;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
{{>licenseInfo}}
{{#models}}
{{#model}}
{{#oneOf}}
{{#-first}}{{>model/modelOneOf}}{{/-first}}
{{/oneOf}}
{{^oneOf}}
{{#tsImports}}
import { {{classname}} } from '{{filename}}{{importFileExtension}}';
{{/tsImports}}
import { AttributeTypeMapEntry } from '../models/ModelTypes{{importFileExtension}}';
import { HttpFile } from '../http/http{{importFileExtension}}';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we sure this is not used in the oneOf case?


{{#description}}
Expand All @@ -12,10 +17,6 @@ import { HttpFile } from '../http/http{{importFileExtension}}';
*/
{{/description}}
{{^isEnum}}
{{#oneOf}}
{{#-first}}{{>model/modelOneOf}}{{/-first}}
{{/oneOf}}
{{^oneOf}}
export class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{
{{#vars}}
{{#description}}
Expand Down Expand Up @@ -49,13 +50,14 @@ export class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{
{{/hasDiscriminatorWithNonEmptyMapping}}

{{^isArray}}
static {{#parent}}override {{/parent}}readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [
static {{#parent}}override {{/parent}}readonly attributeTypeMap: Array<AttributeTypeMapEntry> = [
{{#vars}}
{
"name": "{{name}}",
"baseName": "{{baseName}}",
"type": "{{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}",
"format": "{{dataFormat}}"
"format": "{{dataFormat}}",
"required": {{required}}
}{{^-last}},
{{/-last}}
{{/vars}}
Expand Down Expand Up @@ -100,7 +102,6 @@ export enum {{classname}}{{enumName}} {
{{/vars}}

{{/hasEnums}}
{{/oneOf}}
{{/isEnum}}
{{#isEnum}}
export enum {{classname}} {
Expand All @@ -111,5 +112,6 @@ export enum {{classname}} {
{{/allowableValues}}
}
{{/isEnum}}
{{/oneOf}}
{{/model}}
{{/models}}
Loading
Loading