Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

typescript union should be implemented as anyOf #671

Closed
2 of 4 tasks
fantapop opened this issue Apr 24, 2020 · 16 comments · Fixed by #673
Closed
2 of 4 tasks

typescript union should be implemented as anyOf #671

fantapop opened this issue Apr 24, 2020 · 16 comments · Fixed by #673

Comments

@fantapop
Copy link
Contributor

Digging through the openapi docs, I ran across this:

https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/#anyof-vs-oneof

It seems that oneOf is an exclusive OR. I believe the correct thing to use for unions is anyOf.

Sorting

  • I'm submitting a ...

    • bug report
    • feature request
    • support request
  • I confirm that I

    • used the search to make sure that a similar issue hasn't already been submit

Context (Environment)

Version of the library: 3.0.5
Version of NodeJS: 12.12

  • Confirm you were using yarn not npm: [x]
@WoH
Copy link
Collaborator

WoH commented Apr 24, 2020

There is no perfect equivalent, but here is why oneOf was chosen:

interface LeftType {
    value1: number,
}
interface RightType {
    value2: number
}

type ExampleUnion = LeftType | RightType;

If you try to assign {value1: "", value2: ""}, TS will complain and so will tsoa if you throw on additional Properties.

Edit: I have changed the Union definition to actually show the issue.

@fantapop
Copy link
Contributor Author

I wasn't aware of this until doing some tests just now, but this is actually valid. There's no errors here.

type U = { value1: string } | {value2: string};
const v: U = {value1: "it", value2: "works"};
console.log(v);

There's some info about it here:
https://devblogs.microsoft.com/typescript/announcing-typescript-3-5-rc/#improved-excess-property-checks-in-union-types

Is TSOA validating this as a disjointed union?

Before learning that typescript is loose with its unions, my concern with oneOf was about overlapping types. These are somewhat contrived examples but I think easier to reason about than an example with objects.

type Five = 5;
type FiveOrNumber = Five | number;

this would get sent over the wire something like:

FiveOrNumber:
  oneOf:
    - $ref: '#/components/schemas/Five'
    - type: number

According to the open api docs this type should match any number except for 5 because in the case of 5 it will match both the type number and the type Five. In the typescript version that was written in tsoa it will match any number including the number 5.

Here's a similar one with objects:

interface Cat {
  name: string;
  type?: CatBreed;
}
interface Dog {
  name: string;
  type?: DogBreed;
}
type Pet = Cat | Dog;

If someone passed in the following valid request:

{
  "name": "bubbles"
}

using a oneOf should deny the request because it matches both Cat and Dog
use an anyOf should allow the request because it matched one or more.

I don't know if this is how you think about TSOA, but to me, an ideal case is that typescript is created in TSOA, then converted to openapi, then if a typescript client is generated from the spec, its as close as possible to the original. I realize that getting this perfect will never occur partially because of the expressiveness of the language but also due to ambiguities in how things are implemented.

@WoH
Copy link
Collaborator

WoH commented Apr 25, 2020

Please read the discussion we had here: #400 (comment)

You're right about TS not complaining because their excess property checks are sometimes not as strict as you'd expect and want yet (for example in the sample I initially used, which was not the one I should've gone for and subsequently changed it to better reflect the issue).
The way I interpreted the Article is that it's not valid, but unchecked.

If you have additional concerns, feel free to bring them up.

I'm not sure about TS, but it seems like the Typechecker merges the declarations (for performance reasons) and then struggles to check excess properties. We "save" the member definitions so we can match against each one (we only have to check once at the system boundary, so a first-match approach is fine here).
It's important here to remember that tsoa allows an additionalProp config. If you "silently ignore" additional Properties you'd get pretty close to the old way TS was validating unions.
If you "throw on excess", we match only against the first exact match without excess/additional properties.

@fantapop
Copy link
Contributor Author

fantapop commented Apr 25, 2020 via email

@WoH
Copy link
Collaborator

WoH commented Apr 25, 2020

There is a typo in the example @dgreene1 included as justification. Notice
that the validation is failing becausevalue is not a member of either of the types. It should be value2 in that example.

I didn't even notice that, oops.
Certainly would've tried pushing for anyOf in that case. I'd be curious to see what the other codegens are doing. Classifying this as a bug for now.

@WoH WoH added the bug label Apr 25, 2020
@WoH
Copy link
Collaborator

WoH commented Apr 25, 2020

I'll have to think about the validation implications of this, but maybe we can live without changes for now. If you allow excess props you're already ok, if you wanna throw on excess props I'd assume you'd intuitively want to have the excess prop validation to be as strict as possible and not allow mixed schemas. However, that's the point where we would diverge from OpenAPI and what TS currently does which should not be the case.

@fantapop
Copy link
Contributor Author

I found this open issue. If you read through, there’s clearly a lot of issues implementing this in many languages. Notably this comment sums it up

OpenAPITools/openapi-generator#15 (comment)

I’m curious to see where all this ends up. I personally feel like the spec is pretty explicit about oneOf only allowing one and only one match. This is a useful construct For matching parameter objects of different types. I’m surprised typescript doesn’t have better first class support for this.

@fantapop
Copy link
Contributor Author

here is a post I found informative on the subject. They even call the final product they end up with OneOf

https://timhwang21.gitbook.io/index/programming/typescript/xor-type

@derolf
Copy link

derolf commented Sep 7, 2021

AllOf: That’s just wrong.

@WoH
Copy link
Collaborator

WoH commented Sep 8, 2021

That’s just wrong.

That's what I thought. But I'm sure once you read this discussion and the referenced threads, you'll understand how TypeScript handles union types by default and the implied complexity of oneOf validation without discriminators you seem to be suggesting. But be careful, you may eventually end up changing your preference (like me) and start leaning towards modeling TS Unions as anyOf, or at least be able to provide a nuanced opinion (one which would model as oneOf only in certain circumstances). Good luck until then 🍀

@derolf
Copy link

derolf commented Sep 8, 2021

@WoH , what I actually did was dropping TSOA and using https://github.com/Eywek/typoa . Works like a charm!

@derolf
Copy link

derolf commented Sep 8, 2021

Btw. how do you model type AorB = { a: number, b: string } | { a: string } in TSOA?

@derolf
Copy link

derolf commented Sep 8, 2021

Btw. reading through the thread: The alternative (better) solution to turning a type union into a weird allOf would have been to make TSOA bark if it finds a "type union" that would create a problem with oneOf.

Just imagine how a codegen would turn that allOf back into code: A | B -> TSOA -> allOf -> codegen -> Partial<A> & Partial<B>, which is completely wrong.

So, why going mad because of an artificial corner constructed above (see #671 (comment))? Why not make a good solution for 99% of the benign cases.

@fantapop
Copy link
Contributor Author

fantapop commented Sep 8, 2021

@derolf Are you talking about allOf or anyOf? I thought allOf was the least contentious of them.

from open api spec here: allOf – validates the value against all the subschemas

In TSOA intersection is converted to allOf not union. See here

@derolf
Copy link

derolf commented Sep 9, 2021

What about a compromise?

Usually, when one uses a union, the rational is most of the time indeed to model some xor-kind of relationship.

If TSOA detects “conflicting” properties that rule out that two subtypes can coexist, it could generate oneOfs.

@fantapop
Copy link
Contributor Author

fantapop commented Sep 9, 2021

@derolf Can you give an example of the type and what you're hoping the outcome of the TSOA transformation would be? The code that transforms unions has a number of different cases depending on how many there are and whether they're nullable or not. It's somewhat complex. Another ideal is that the generated output can then be run through the OpenAPI codegen process to regenerate the same or functionaly equivalent code. This is not always possible currently but hopefully it should get better as Open API and typescript become more expressive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants