openapi-typescript icon indicating copy to clipboard operation
openapi-typescript copied to clipboard

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

Open LostInStatic opened this issue 1 year ago • 3 comments
trafficstars

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 avatar Jan 28 '24 21:01 LostInStatic

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.

drwpow avatar Jan 28 '24 22:01 drwpow

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.

sbaechler avatar Mar 14 '24 13:03 sbaechler

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.

mattoni avatar Jul 23 '24 20:07 mattoni