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

$ref in allOf with depth of 2 is not correctly converted when multi-referenced #597

Closed
arnauddrain opened this issue May 29, 2024 · 5 comments

Comments

@arnauddrain
Copy link

I'm trying to reproduce an inheritance schema like this:

Thing
 |
Vehicle
 |     \ 
Car     Truck

Using the "allOf" keyword with a schema like this:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "test",
  "definitions": {
    "Thing": {
      "type": "object",
      "properties": {
        "name": { "type": "string" }
      },
      "required": ["name"]
    },
    "Vehicle": {
      "type": "object",
      "allOf": [{ "$ref": "#/definitions/Thing" }],
      "properties": {
        "year": { "type": "integer" }
      },
      "required": ["year"]
    },
    "Car": {
      "type": "object",
      "allOf": [{ "$ref": "#/definitions/Vehicle" }],
      "properties": {
        "numDoors": { "type": "integer" }
      },
      "required": ["numDoors"]
    },
    "Truck": {
      "type": "object",
      "allOf": [{ "$ref": "#/definitions/Vehicle" }],
      "properties": {
        "numAxles": { "type": "integer" }
      },
      "required": ["numAxles"]
    }
  },
  "oneOf": [{ "$ref": "#/definitions/Car" }, { "$ref": "#/definitions/Truck" }]
}

The output looks like this:

export type Test = Car | Truck;
export type Car = Car1 & {
  numDoors: number;
};
export type Car1 = Vehicle; // <--- This is expected
export type Vehicle = Vehicle1 & {
  year: number;
};
export type Vehicle1 = Thing;
export type Truck = Truck1 & {
  numAxles: number;
};
export type Truck1 = Vehicle1; // <--- This is unexpected, should be Vehicle
  name: string;
}

Here, Truck is directly inheriting Thing instead of Vehicle, so its TypeScript definition does not includes the year property.
If I exchange the properties of the root oneOf, then the the issue appears in the type definition of Car.

@arnauddrain arnauddrain changed the title AllOf with depth of 2 is not corrently converted when multi-referenced AllOf with depth of 2 is not correctly converted when multi-referenced May 29, 2024
@arnauddrain arnauddrain changed the title AllOf with depth of 2 is not correctly converted when multi-referenced $ref in allOf with depth of 2 is not correctly converted when multi-referenced May 30, 2024
@bcherny
Copy link
Owner

bcherny commented Jun 2, 2024

This does seem like a bug.

In your case, I think you actually want extends, which would fix it for you:

https://borischerny.com/json-schema-to-typescript-browser/#schema=%7B%0A%20%20%22$schema%22:%20%22http://json-schema.org/draft-07/schema#%22,%0A%20%20%22$id%22:%20%22test%22,%0A%20%20%22definitions%22:%20%7B%0A%20%20%20%20%22Thing%22:%20%7B%0A%20%20%20%20%20%20%22type%22:%20%22object%22,%0A%20%20%20%20%20%20%22properties%22:%20%7B%0A%20%20%20%20%20%20%20%20%22name%22:%20%7B%20%22type%22:%20%22string%22%20%7D%0A%20%20%20%20%20%20%7D,%0A%20%20%20%20%20%20%22required%22:%20%5B%22name%22%5D%0A%20%20%20%20%7D,%0A%20%20%20%20%22Vehicle%22:%20%7B%0A%20%20%20%20%20%20%22type%22:%20%22object%22,%0A%20%20%20%20%20%20%22extends%22:%20%7B%20%22$ref%22:%20%22#/definitions/Thing%22%20%7D,%0A%20%20%20%20%20%20%22properties%22:%20%7B%0A%20%20%20%20%20%20%20%20%22year%22:%20%7B%20%22type%22:%20%22integer%22%20%7D%0A%20%20%20%20%20%20%7D,%0A%20%20%20%20%20%20%22required%22:%20%5B%22year%22%5D%0A%20%20%20%20%7D,%0A%20%20%20%20%22Car%22:%20%7B%0A%20%20%20%20%20%20%22type%22:%20%22object%22,%0A%20%20%20%20%20%20%22extends%22:%20%7B%20%22$ref%22:%20%22#/definitions/Vehicle%22%20%7D,%0A%20%20%20%20%20%20%22properties%22:%20%7B%0A%20%20%20%20%20%20%20%20%22numDoors%22:%20%7B%20%22type%22:%20%22integer%22%20%7D%0A%20%20%20%20%20%20%7D,%0A%20%20%20%20%20%20%22required%22:%20%5B%22numDoors%22%5D%0A%20%20%20%20%7D,%0A%20%20%20%20%22Truck%22:%20%7B%0A%20%20%20%20%20%20%22type%22:%20%22object%22,%0A%20%20%20%20%20%20%22extends%22:%20%7B%20%22$ref%22:%20%22#/definitions/Vehicle%22%20%7D,%0A%20%20%20%20%20%20%22properties%22:%20%7B%0A%20%20%20%20%20%20%20%20%22numAxles%22:%20%7B%20%22type%22:%20%22integer%22%20%7D%0A%20%20%20%20%20%20%7D,%0A%20%20%20%20%20%20%22required%22:%20%5B%22numAxles%22%5D%0A%20%20%20%20%7D%0A%20%20%7D,%0A%20%20%22oneOf%22:%20%5B%7B%20%22$ref%22:%20%22#/definitions/Car%22%20%7D,%20%7B%20%22$ref%22:%20%22#/definitions/Truck%22%20%7D%5D%0A%7D

@arnauddrain
Copy link
Author

Thank you for your answer @bcherny !
extends will indeed generate the correct typescript classes, but then the validation will be incorrect, because the properties of the "parent" objets won't be required anymore and {"numDoors": 2} will become a valid input: https://jsonschema.dev/s/TdKME

@bcherny
Copy link
Owner

bcherny commented Jun 7, 2024

@arnauddrain extends is an old keyword that has been removed in modern JSON Schema versions. The website you linked doesn't seem to implement it correctly. If your validator supports it, extends will work fine the way I suggested.

See the spec: https://github.com/json-schema/json-schema/wiki/Extends/014e3cd8692250baad70c361dd81f6119ad0f696

altearius added a commit to altearius/json-schema-to-typescript that referenced this issue Jun 23, 2024
Description

- Given a schema that contains a named definition (`Level2B`),
- And that named definition is referenced in multiple locations,
- And that named schema is also an intersection type (`allOf` in this
  example),

Then when parsed, the generated TypeScript will contain the correct
reference only for the _first_ location in which the named schema is
encountered, during a depth-first traversal.

Subsequent references to the same schema will be generated as though
they were only the intersection type, and not the named schema.

Example

Given the following schema:

```yaml
description: Top Level
type: object
oneOf:
  - $ref: '#/definitions/Level2A'
  - $ref: '#/definitions/Level2B'

definitions:
  Level2A:
    description: Level2A
    type: object
    allOf: [$ref: '#/definitions/Base']
    properties:
      level_2A_ref: { $ref: '#/definitions/Level2B' }
  Level2B:
    description: Level2B
    type: object
    allOf: [$ref: '#/definitions/Base']
    properties:
      level_2B_prop: { const: xyzzy }
  Base:
    description: Base
    type: object
    properties:
      base_prop: { type: string }
```

The current resulting TypeScript will be (comments adjusted
for clarity):

```ts
// Incorrect: should be `type Demo = Level2A | Level2B`
// Note that the Level2B type at this location is the _second_ instance
// to Level2B during a depth-first traversal.
export type Demo = Level2A | Level2B1;

export type Level2A = Level2A1 & {
  // Correct in this location because this property is reached first in
  // a depth-first traversal.
  level_2A_ref?: Level2B;
  [k: string]: unknown;
};

export type Level2A1 = Base;

export type Level2B = Level2B1 & {
  level_2B_prop?: "xyzzy";
  [k: string]: unknown;
};

export type Level2B1 = Base;

export interface Base {
  base_prop?: string;
  [k: string]: unknown;
}
```

Root Cause

In `parser.ts`, [lines 57 - 75][1], when schema that matches multiple
"types" is encountered, the parser generates a new `ALL_OF` intersection
schema to contain each sub-type, then adds each sub-type to the new
`ALL_OF` schema.

Each sub-type is then parsed sequentially. During this process,
`maybeStripNameHints` is called, which mutates the schema by removing
the `$id`, `description`, and `name` properties.

Notably, these properties are used by `typesOfSchema` to detect the
`NAMED_SCHEMA` type. As a result, this schema object will never again be
detected as a `NAMED_SCHEMA` type.

Therefore, the _first_ instance of the schema object is correctly
handled as an intersection schema **and** a named schema, but all
subsequent instances are treated as though they are **only** an
intersection schema.

Proposed Solution

- The call to `typesOfSchema` is moved from `parser.ts` to
  `normalizer.ts`, with the goal of avoiding confusion due to a mutated
  schema object. The resulting list of schema types is persisted as a
  `$types` property on the schema.

- The generated intersection schema is _also_ moved from `parser.ts` to
  `normalizer.ts`. This is because it is advantageous to let the
  generated intersection schema participate in the caching mechanism
  (which it could not previously do, since it was generated dynamically
  during each encounter). Without this, multiple instances of the same
  schema are generated.

Related Issues

- bcherny#597

[1]: https://github.com/bcherny/json-schema-to-typescript/blob/31993def993b610ba238d3024260129e31ddc371/src/parser.ts#L57-L75 'parser.ts, lines 57 - 75'
altearius added a commit to altearius/json-schema-to-typescript that referenced this issue Jun 24, 2024
- Given a schema that contains a named definition (`B`),
- And that named definition is referenced in multiple locations,
- And that named schema is also an intersection type (`allOf` in this
  example),

Then when parsed, the generated TypeScript will contain the correct
reference only for the _first_ location in which the named schema is
encountered, during a depth-first traversal.

Subsequent references to the same schema will be generated as though
they were only the intersection type, and not the named schema.

Example

Given the following schema:

```yaml
$id: Intersection
type: object
oneOf:
  - $ref: '#/definitions/A'
  - $ref: '#/definitions/B'

definitions:
  A:
    type: object
    additionalProperties: false
    allOf: [$ref: '#/definitions/Base']
    properties:
      b: {$ref: '#/definitions/B'}
  B:
    type: object
    additionalProperties: false,
    allOf: [$ref: '#/definitions/Base']
    properties:
      x: {type: string}
  Base:
    type: object
    additionalProperties: false,
    properties:
      y: {type: string}
```

The current resulting TypeScript will be (comments adjusted
for clarity):

```ts
// Incorrect: should be `type Intersection = A | B`
// Note that the B type at this location is the _second_ reference to
// B during a depth-first traversal.
export type Intersection = A | B1;
export type A = A1 & {
  b?: B;
};
export type A1 = Base;
export type B = B1 & {
  x?: string;
};
export type B1 = Base;

export interface Base {
  y?: string;
}
```

Root Cause

In `parser.ts`, [lines 57 - 75][1], when schema that matches multiple
"types" is encountered, the parser generates a new `ALL_OF` intersection
schema to contain each sub-type, then adds each sub-type to the new
`ALL_OF` schema.

Each sub-type is then parsed sequentially. During this process,
`maybeStripNameHints` is called, which mutates the schema by removing
the `$id`, `description`, and `name` properties.

Notably, these properties are used by `typesOfSchema` to detect the
`NAMED_SCHEMA` type. As a result, this schema object will never again be
detected as a `NAMED_SCHEMA` type.

Therefore, the _first_ instance of the schema object is correctly
handled as an intersection schema **and** a named schema, but all
subsequent instances are treated as though they are **only** an
intersection schema.

Proposed Solution

- The call to `typesOfSchema` is moved from `parser.ts` to
  `normalizer.ts`, with the goal of avoiding confusion due to a mutated
  schema object. The resulting list of schema types is persisted on the
  schema using a newly-introduced `Types` symbol.

- The generated intersection schema is _also_ moved from `parser.ts` to
  `normalizer.ts`. This is because it is advantageous to let the
  generated intersection schema participate in the caching mechanism
  (which it could not previously do, since it was generated dynamically
  during each encounter). Without this, multiple instances of the same
  schema are generated.

Related Issues

- bcherny#597

[1]: https://github.com/bcherny/json-schema-to-typescript/blob/31993def993b610ba238d3024260129e31ddc371/src/parser.ts#L57-L75 'parser.ts, lines 57 - 75'
bcherny pushed a commit that referenced this issue Jul 22, 2024
* Add new test cases.

* Intersection schema generation is order-dependent

- Given a schema that contains a named definition (`B`),
- And that named definition is referenced in multiple locations,
- And that named schema is also an intersection type (`allOf` in this
  example),

Then when parsed, the generated TypeScript will contain the correct
reference only for the _first_ location in which the named schema is
encountered, during a depth-first traversal.

Subsequent references to the same schema will be generated as though
they were only the intersection type, and not the named schema.

Example

Given the following schema:

```yaml
$id: Intersection
type: object
oneOf:
  - $ref: '#/definitions/A'
  - $ref: '#/definitions/B'

definitions:
  A:
    type: object
    additionalProperties: false
    allOf: [$ref: '#/definitions/Base']
    properties:
      b: {$ref: '#/definitions/B'}
  B:
    type: object
    additionalProperties: false,
    allOf: [$ref: '#/definitions/Base']
    properties:
      x: {type: string}
  Base:
    type: object
    additionalProperties: false,
    properties:
      y: {type: string}
```

The current resulting TypeScript will be (comments adjusted
for clarity):

```ts
// Incorrect: should be `type Intersection = A | B`
// Note that the B type at this location is the _second_ reference to
// B during a depth-first traversal.
export type Intersection = A | B1;
export type A = A1 & {
  b?: B;
};
export type A1 = Base;
export type B = B1 & {
  x?: string;
};
export type B1 = Base;

export interface Base {
  y?: string;
}
```

Root Cause

In `parser.ts`, [lines 57 - 75][1], when schema that matches multiple
"types" is encountered, the parser generates a new `ALL_OF` intersection
schema to contain each sub-type, then adds each sub-type to the new
`ALL_OF` schema.

Each sub-type is then parsed sequentially. During this process,
`maybeStripNameHints` is called, which mutates the schema by removing
the `$id`, `description`, and `name` properties.

Notably, these properties are used by `typesOfSchema` to detect the
`NAMED_SCHEMA` type. As a result, this schema object will never again be
detected as a `NAMED_SCHEMA` type.

Therefore, the _first_ instance of the schema object is correctly
handled as an intersection schema **and** a named schema, but all
subsequent instances are treated as though they are **only** an
intersection schema.

Proposed Solution

- The call to `typesOfSchema` is moved from `parser.ts` to
  `normalizer.ts`, with the goal of avoiding confusion due to a mutated
  schema object. The resulting list of schema types is persisted on the
  schema using a newly-introduced `Types` symbol.

- The generated intersection schema is _also_ moved from `parser.ts` to
  `normalizer.ts`. This is because it is advantageous to let the
  generated intersection schema participate in the caching mechanism
  (which it could not previously do, since it was generated dynamically
  during each encounter). Without this, multiple instances of the same
  schema are generated.

Related Issues

- #597

[1]: https://github.com/bcherny/json-schema-to-typescript/blob/31993def993b610ba238d3024260129e31ddc371/src/parser.ts#L57-L75 'parser.ts, lines 57 - 75'

* Additionally hoist `allOf` behavior.

* Traverse the generated intersection schema.
@bcherny
Copy link
Owner

bcherny commented Jul 22, 2024

Merged #603

@bcherny bcherny closed this as completed Jul 22, 2024
@bcherny
Copy link
Owner

bcherny commented Jul 22, 2024

Fix included in v15.0.0.

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

No branches or pull requests

2 participants