[typescript-fetch] Add generateAnyOfAsUnion option to model anyOf as a union type
Context
typescript-fetch generates an anyOf of $ref schemas as one interface that merges the properties and the required lists of all members:
PetData:
anyOf:
- $ref: '#/components/schemas/Cat' # required: [meowCount]
- $ref: '#/components/schemas/Dog' # required: [barkCount, tailLength]
becomes
export interface PetData {
meowCount: number;
barkCount: number;
tailLength: number;
breed?: string;
}
so neither a Cat nor a Dog value type-checks on its own. The same schema with oneOf gives export type PetData = Cat | Dog;. An anyOf of primitives (number, string) gives an empty interface (#19155).
Change
A new option generateAnyOfAsUnion (default false, so existing output is unchanged). When it is on, an anyOf model goes through the existing oneOf path:
- the type is a union (
Cat | Dog,number | string,Array<Dog> | Cat | string), in both the separate-model andwithoutRuntimeChecksmodes; instanceOf*,FromJSONandToJSONare generated as foroneOf, with one difference that follows fromanyOfallowing several members to match: for object members, the result combines every matching member ({ ...CatToJSON(value), ...DogToJSON(value) }), so a value matching both keeps all its properties. Primitive and array members keep first match, as a value can only be one of them. AnanyOfwith a discriminator keeps the discriminatorswitchand does not combine;nullis added to the type when at least one member is nullable (foroneOfit stays exactly one, as in #25100).
If two matching members declare the same property with different types, the last matching member wins; members are not reconciled. An anyOf schema that also declares its own properties stays an interface, so those properties aren't lost.
Changes to the shared union template (also affect oneOf)
Review feedback on this PR found problems in modelOneOf.mustache that oneOf already has on master. Since anyOf now uses the same template, they are fixed here, for both:
- Object members are checked only inside
if (typeof json === 'object'). Before,if (typeof json !== 'object') return json;returned every primitive before the declared primitive checks, which were unreachable. - As a result,
date/date-timemembers of a union with model members are now parsed and serialized (before, the raw value was returned). - A primitive that matches no declared member now yields
{}, as an unmatched object already did, instead of being returned unchanged. Enum members keep working:oneOfModelsis split into object and enum models, and enum members are checked outside the object block. - Array members check
item !== nullbefore callinginstanceOf*, so arrays containingnullno longer throw.
Valid input decodes and encodes as before, apart from the date members. The oneOf and oneOf-not-enum samples change accordingly.
Why an option and not a custom template
CONTRIBUTING asks to consider -t before adding an option. That isn't enough here: the member properties are merged in DefaultCodegen before templates run, and oneOfModels, oneOfArrays, oneOfPrimitives and the import filtering in TypeScriptFetchClientCodegen are computed from oneOf only, so a template override cannot produce the union, its imports or its (de)serializers. Changing the default would break every existing anyOf user, so it is opt-in, like generateOneOfAnyOfWrappers in the Kotlin generator.
Tests
TypeScriptFetchClientCodegenTest: default output unchanged (merged interface); with the option on, unions for a top-levelanyOf, an inlineanyOfproperty, a primitiveanyOf, nullable members, a discriminatedanyOfand a model/array/primitive mix, in both runtime-check modes; combinedFromJSON/ToJSONfor a value matching several members; ananyOfwith own properties stays an interface;oneOfoutput is the same with the option on and off; null array items, unreachable-primitive and enum/date-time member cases foroneOfandanyOf. Spec:src/test/resources/3_0/typescript-fetch/anyOf.yaml.- New sample config
bin/configs/typescript-fetch-any-of-union.yaml→samples/client/petstore/typescript-fetch/builds/any-of-union, which type-checks withtsc --noEmit. All samples were regenerated; besides the new one, only theoneOfandoneOf-not-enumtypescript-fetch samples change (template changes above).
Related: #6376, #19155, #12432, #8894
PR checklist
- Read the contribution guidelines.
- Run the following to build the project and update samples:
Commit all changed files../mvnw clean package || exit ./bin/generate-samples.sh ./bin/configs/*.yaml || exit ./bin/utils/export_docs_generators.sh || exit - If your PR is targeting a particular programming language, @mention the technical committee members, so they are more likely to review the pull request.
cc @macjohnny @joscha