json-schema-to-typescript icon indicating copy to clipboard operation
json-schema-to-typescript copied to clipboard

Duplication of type : properties with different description

Open maxime-agusti opened this issue 6 years ago • 14 comments

Hi there !

I have a problem with property, ref and description. If two properties of a type have the same ref definitions and two different descriptions, json2ts duplicate the type definition.

In the following case, I have a type named Offer containing two properties price and priceInclVAT of the same type Price but two different descriptions "Price excl. VAT" and "Price incl. VAT". Output from json2ts creates two types Price and Price1 and put comments over interface definitions instead of property definitions.

Source JSON Schema :

{
    "title": "Offer",
    "description": "An offer",
    "type": "object",
    "properties": {
        "price": {
            "description": "Price excl. VAT",
            "$ref": "#/definitions/Price"
        },
        "priceInclVAT": {
            "description": "Price incl. VAT",
            "$ref": "#/definitions/Price"
        }
    },
    "definitions": {
        "Price": {
            "title": "Price",
            "description": "A price",
            "type": "object",
            "properties": {
                "value": {
                    "description": "Price as number",
                    "type": "number"
                },
                "text": {
                    "description": "Price as string",
                    "type": "string"
                }
            }
        }
    }
}

What I obtained using json2ts :

/**
 * An offer
 */
export interface Offer {
    price: Price;
    priceInclVAT: Price1;
}

/**
 * Price excl. VAT
 */
export interface Price {
    /**
     * Price as number
     */
    value: number;
    /**
     * Price as string
     */
    text: string;
}

/**
 * Price incl. VAT
 */
export interface Price1 {
    /**
     * Price as number
     */
    value: number;
    /**
     * Price as string
     */
    text: string;
}

What I expected :

/**
 * An offer
 */
export interface Offer {
    /**
     * Price excl. VAT
     */
    price: Price;
    /**
     * Price incl. VAT
     */
    priceInclVAT: Price;
}
/**
 * A price
 */
export interface Price {
    /**
     * Price as number
     */
    value: number;
    /**
     * Price as string
     */
    text: string;
}

maxime-agusti avatar Sep 25 '18 14:09 maxime-agusti

Hey @maximeag! I think we'll need a few more examples from others to make a call on this one. I'll leave it open for now.

For what it's worth, since TypeScript has a structural type system, Price and Price1 are totally interchangeable (you can use a Price1 where a Price is required, and vice-versa).

bcherny avatar Nov 12 '18 23:11 bcherny

I also think it makes sense to use the descriptions to annotate the fields, instead of creating new interfaces. I think that the description field in JsonSchema never actually implies a difference in schema behavior, which is why the multiple generated interfaces are interchangeable, and would be better mapped to annotations instead, as suggested.

austince avatar Dec 20 '18 17:12 austince

We also don't see value in duplicated interface

Yuripetusko avatar Feb 15 '19 10:02 Yuripetusko

I am desperately wanting an extensively documented API, so all types and all properties have descriptions; but I also don't want dupe types generated, it causes a lot of noise to consumers of my api client. Would really appreciate if this was changed so differing property descriptions do not cause new types to be generated.

vanslly avatar Jul 01 '19 04:07 vanslly

For what it's worth, since TypeScript has a structural type system, Price and Price1 are totally interchangeable (you can use a Price1 where a Price is required, and vice-versa).

This is not true when using ts string enums.

I.e.

{
    "title": "Foo",
    "type": "object",
    "properties": {
        "bar": {
            "description": "bar",
            "$ref": "#/definitions/qux"
        },
        "baz": {
            "description": "baz",
            "$ref": "#/definitions/qux"
        }
    },
    "definitions": {
        "qux": {
            "title": "Qux",
            "description": "A price",
            "enum": ["quux", "quuz"],
            "tsEnumNames": ["Quux", "Quuz"]
        }
    }
}

Results in:

export interface Foo {
  bar?: Qux;
  baz?: Qux1;
  [k: string]: any;
}

/**
 * bar
 */
export enum Qux {
  Quux = "quux",
  Quuz = "quuz"
}
/**
 * baz
 */
export enum Qux1 {
  Quux = "quux",
  Quuz = "quuz"
}

Here, Qux and Qux1 are not interchangeable.

image

I agree with the sentiment described in this issue.

nicojs avatar Jan 25 '20 21:01 nicojs

It's not only the 'description' field that forces 2 duplicate implementations. Also the default results in the same behavior.

I.e.

{
    "title": "Foo",
    "type": "object",
    "properties": {
        "bar": {
            "$ref": "#/definitions/qux",
            "default": "quuz"
        },
        "baz": {
            "$ref": "#/definitions/qux"
        }
    },
    "definitions": {
        "qux": {
            "title": "Qux",
            "description": "A price",
            "enum": ["quux", "quuz"],
            "tsEnumNames": ["Quux", "Quuz"]
        }
    }
}

Results in:


export interface Foo {
  bar?: Qux;
  baz?: Qux1;
  [k: string]: any;
}

/**
 * A price
 */
export enum Qux {
  Quux = "quux",
  Quuz = "quuz"
}
/**
 * A price
 */
export enum Qux1 {
  Quux = "quux",
  Quuz = "quuz"
}

nicojs avatar Jan 25 '20 21:01 nicojs

A workaround is to remove all other properties (with the exception of "$ref") before generating the schema.

function preprocessSchema(inputSchema) {
  switch (schemaType.type) {
    case 'object':
      const outputSchema = {
        ...inputSchema,
        properties: preprocessProperties(inputSchema.properties),
        definitions: preprocessProperties(inputSchema.definitions),
      };
      return outputSchema;
    case 'array':
      return {
        ...inputSchema,
        items: preprocessSchema(inputSchema.items),
      };
    default:
      if (inputSchema.$ref) {
        // Workaround for: https://github.com/bcherny/json-schema-to-typescript/issues/193
        return {
          $ref: inputSchema.$ref,
        };
      }
      if (inputSchema.allOf) {
        return {
          allOf: inputSchema.allOf.map(preprocessSchema),
          definitions: preprocessProperties(inputSchema.definitions),
        };
      }
      if (inputSchema.anyOf) {
        return {
          anyOf: inputSchema.anyOf.map(preprocessSchema),
          definitions: preprocessProperties(inputSchema.definitions),
        };
      }
      return inputSchema;
  }
}

function preprocessProperties(inputProperties) {
  if (inputProperties) {
    const outputProperties = {};
    Object.entries(inputProperties).forEach(([name, value]) => {
      outputProperties[name] = preprocessSchema(value);
    });
    return outputProperties;
  }
  return undefined;
}

const schemaFile = '...';
const outFileName = '..';
const schema = preprocessSchema(require(schemaFile));
const ts = await compile(schema, schemaFile, {
  style: {
    singleQuote: true,
  },
});
fs.writeFileSync(outFileName, ts, 'utf8');

nicojs avatar Mar 10 '20 09:03 nicojs

I fixed it with #318 for internal refs to #/definitions/ only. It has a new option resolve which you have to set to false to let the $ref be handled by json-schema-to-typescript. This does mean losing external refs in that mode. I guess ideally json-schema-ref-parser would have an option to let internal $refs through.

davedoesdev avatar Jul 04 '20 07:07 davedoesdev