Seventh pattern of our series: composed types. JSON schema defines 3 main ways of composing types:
- allOf: the data must validate all the schemas described in the collection. This is often mapped to inheritance in Object Oriented Programming (OOP) although it wasn’t designed for that purpose.
- anyOf: the data must validate one or more of the schemas. Often mapped to inclusive unions/intersections. (& sign in TypeScript)
- oneOf: the data must validate a single schema in the collection. Often mapped to exclusive unions. (| sign in TypeScript)
This breaks down for many reasons. Let’s see why.
allOf – inheritance
In the following snippet you can see multiple examples. While Bar and Baz are straight-forward, you’ll notice a difference, one has a single allOf entry and properties defined directly, the other one contains two entries, a referenced component and an inline schema.
components: schemas: Foo: type: object properties: one: type: number nullable: true Bar: type: object allOf: - $ref: '#/components/schemas/Foo' properties: two: type: string nullable: true Baz: allOf: - $ref: '#/components/schemas/Foo' - type: object properties: three: type: string nullable: true
This breaks further if we have more than 2 entries in the allOf property and the target language doesn’t support multiple-parents inheritance.
I do think we could fix that by introducing a “main parent” property which would effectively let the generator which type to pick in case of unsupported multi-inheritance by the target language. Alternatively, we could provide guidelines to generator to avoid generating inherited types when more than two entries are present and generate wrapper types instead (more on that in the anyOf/oneOf sections).
Linters could also come to our rescue here implementing several rules:
- Warn when using more than one entry in the allOf property.
- Error when using the properties property in combination with the allOf property.
- Warn when not providing discriminator information.
anyOf/oneOf – intersection and union types
The generation logic is fairly similar here (at least in Kiota), we generate types for each of the member types that are not scalar or arrays, and we either take advantage of type composition when supported by the target language or generate a wrapper type with a member property per member type. What differentiates the two will be the serialization/deserialization logic. In the case of a union type, we only ever want to serialize one of the member types, while in the case of the intersection type, we want everything when possible.
An important aspect of the deserialization of object member types, which also impacts all of, is the need for discriminator information so the client knows what type to deserialize to at runtime without having to run an expensive pattern matching engine. Unfortunately, this information is not always present and will only work when using component schemas for the member types.
Union types can also contain scalar member types, in which case we need to brute force our way through during deserialization, having a set of deterministic rules for generators in the OpenAPI documentation would be helpful here.
components: schemas: Foo: oneOf: - type: string - type: boolean - type: number
Similarly, intersection type members can declare conflicting properties (same name, different types), with Kiota we’re choosing to have this as a limitation for the time being, and we’ll re-evaluate depending on the feedback we get from the community. But maybe this is something where linters could help again.
components: schemas: Foo: type: object properties: same: type: boolean nullable: true Bar: type: object properties: same: type: number nullable: true Baz: anyOf: - $ref: '#/components/schemas/Foo' - $ref: '#/components/schemas/Bar' - type: object properties: same: type: string nullable: true