[typescript-fetch] Add generateAnyOfAsUnion option to model anyOf as a union type

FA
1d ago
+3022-51

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 and withoutRuntimeChecks modes;
  • instanceOf*, FromJSON and ToJSON are generated as for oneOf, with one difference that follows from anyOf allowing 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. An anyOf with a discriminator keeps the discriminator switch and does not combine;
  • null is added to the type when at least one member is nullable (for oneOf it 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-time members 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: oneOfModels is split into object and enum models, and enum members are checked outside the object block.
  • Array members check item !== null before calling instanceOf*, so arrays containing null no 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-level anyOf, an inline anyOf property, a primitive anyOf, nullable members, a discriminated anyOf and a model/array/primitive mix, in both runtime-check modes; combined FromJSON/ToJSON for a value matching several members; an anyOf with own properties stays an interface; oneOf output is the same with the option on and off; null array items, unreachable-primitive and enum/date-time member cases for oneOf and anyOf. 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 with tsc --noEmit. All samples were regenerated; besides the new one, only the oneOf and oneOf-not-enum typescript-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:
    ./mvnw clean package || exit
    ./bin/generate-samples.sh ./bin/configs/*.yaml || exit
    ./bin/utils/export_docs_generators.sh || exit
    
    Commit all changed files.
  • 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