Skip to content

Empty object inside of allOf creates Record<string, never> in a type intersection #1520

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

Open
1 of 2 tasks
LostInStatic opened this issue Jan 28, 2024 · 5 comments
Open
1 of 2 tasks
Assignees
Labels
bug Something isn't working openapi-ts Relevant to the openapi-typescript library

Comments

@LostInStatic
Copy link

LostInStatic commented Jan 28, 2024

Description

I've decided to make it an issue mostly in case someone else is about to spend multiple hours figuring out what's wrong. That's why I'm going to be a little verbose - existing issues (#1474) are mostly tangentially related to the root cause, and I could have really used a description like this five hours ago.

If there's an object with no properties in the schema, it will get converted to Record<string, never> - this is the most sensible way to represent an empty object in TS. However, when such object is used in allOf, it is put along other object definitions in a TS type intersection. The way it is interpreted, it is combining other object types with an object that has all possible keys defined as invalid.

Why would you put an object with no properties inside of an allOf? Beats me, but I've seen that pattern before - my best guess is that it is a common way of thinking about inheritance (baseRequest -> specificRequest even if the requests have nothing in common) or some common tooling works this way.

Possible solutions

I ended up just running openapi-typescript with the --empty-objects-unknown CLI flag - this pretty much solved it for my use case, but you lose strict typing around empty objects. You could also add any property to the empty object if you control the schema.

If i had to fix it, I'd probably start with trying to omit anyRecord<string, never> in TS types intersection generation. Unfortunately I don't have space for taking a try at this and making a PR right now.

Hopefully this issue will help in documenting the problem. Cheers!

Reproduction

Run with the following input:

openapi: 3.0.0
components: 
  schemas:
    Empty:
      type: object
    Test: 
      allOf: 
        - $ref: "#/components/schemas/Empty"
        - type: object
          properties:
            foo: 
              type: string

Expected result

[...]
export interface components {
    schemas: {
        Empty: Record<string, never>;
        Test: components["schemas"]["Empty"] & {
            foo?: string;
        }; // "{ }" is the only valid object for this type, even though the schema suggests otherwise.
    };
[...]

Checklist

@LostInStatic LostInStatic added bug Something isn't working openapi-ts Relevant to the openapi-typescript library labels Jan 28, 2024
@drwpow
Copy link
Contributor

drwpow commented Jan 28, 2024

Thanks so much for writing this up. As you’ve probably seen in other issues, there can be interesting challenges presented with complex schema composition, especially with how TypeScript handles unions.

While in some instances it’s better to just make minor schema adjustments for better TS output, I agree in this scenario, I can’t think of any reason why a person would want Record<string, never> as part of an intersection, and we should discard it. I don’t believe it would affect the final type either.

@sbaechler
Copy link

To give some context, this schema is commonly used with Java when the code should be generated from the OpenAPI spec. This is a way to describe inheritance.

@mattoni
Copy link

mattoni commented Jul 23, 2024

This happens any time I have an object represented like this:

source:
    type:
      - object
      - "null"
    description: >
      Describes the source/value of the variable.

      - **raw**: Directly set the value of the variable in the stack.
      - **url**: Cycle will fetch the variable content from a remote source when the container starts.
    discriminator:
      propertyName: type
      mapping:
        url: ./StackSpecScopedVariableUrlSource.yml
        raw: ./StackSpecScopedVariableRawSource.yml
    oneOf:
      - $ref: ./StackSpecScopedVariableUrlSource.yml
      - $ref: ./StackSpecScopedVariableRawSource.yml

where the type is object/null.

@sebastian-fredriksson-bernholtz

I'd just like to note that the existing behaviour is actually consistent with openapi, only that openapi-typescript (as opposed to openapi) defaults to additionalProperties: false.

The openapi-typescript documentation is quite clear about the effect of that default behaviour - which aligns with openapi's behaviour when additionalProperties: false - and how you can use additionalProperties to adjust the behaviour.

You can also provide the --additionalProperties=true to get the same default behaviour in openapi-typescript as in openapi.

@sebastian-fredriksson-bernholtz

@drwpow It looks like the reason for defaulting --additional-properties to false might no longer apply? I'd recommend that rather than having a special rule for empty objects that are part of an allOf, we just change the default for --additional-properties to true, to be consistent with openapi? I think that was the initial intention of that flag.

While in some instances it’s better to just make minor schema adjustments for better TS output, I agree in this scenario, I can’t think of any reason why a person would want Record<string, never> as part of an intersection, and we should discard it. I don’t believe it would affect the final type either.

- #1520 (comment)

The current behaviour is consistent with openapi as long as the default is additionalProperties: false, because only empty object {} actually fulfils:

type: object
additionalProperties: false

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working openapi-ts Relevant to the openapi-typescript library
Projects
None yet
Development

No branches or pull requests

6 participants