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

Nested generic with mapped types produce inconsistent results based on order of interface declaration #1267

Open
2 of 4 tasks
TimoGlastra opened this issue Jun 18, 2022 · 3 comments

Comments

@TimoGlastra
Copy link

TimoGlastra commented Jun 18, 2022

Using nested mapped types with generics (don't know what's exactly at fault here), will produce a different swagger.json file based on the order of method declarations. To be more precise, if the method without generics is used first the type is inferred correctly, however if the generic type variant is used first it won't infer the type correctly. Both interfaces map to the same type, which means tsoa will produce a single type (even though one is based on a nested generic and the other isn't)

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

Expected Behavior

I expect the different usages of the generic type to produce the same interface independant on the order of usage of the generics. The problem here is I think that with nested generics the type can't be determined. If the type without nested generic is used first it will correctly generate the type, while if the methods are switched the type won't be valid.

{
	"components": {
		"examples": {},
		"headers": {},
		"parameters": {},
		"requestBodies": {},
		"responses": {},
		"schemas": {
			"PayloadMap_PayloadTypes_": {
				"properties": {
					"theKey": {
						"properties": {
							"thePayload": {
								"type": "string"
							}
						},
						"required": [
							"thePayload"
						],
						"type": "object"
					}
				},
				"type": "object"
			},
			"TheRequestBodyWithoutGeneric": {
				"properties": {
					"payloadData": {
						"$ref": "#/components/schemas/PayloadMap_PayloadTypes_"
					}
				},
				"required": [
					"payloadData"
				],
				"type": "object",
				"additionalProperties": false
			},
			"TheRequestBodyWithGeneric_PayloadTypes_": {
				"properties": {
					"payloadData": {
						"$ref": "#/components/schemas/PayloadMap_PayloadTypes_"
					}
				},
				"required": [
					"payloadData"
				],
				"type": "object",
				"additionalProperties": false
			}
		},
		"securitySchemes": {}
	},
	"info": {
		"title": "tsoa",
		"version": "1.0.0",
		"license": {
			"name": "MIT"
		},
		"contact": {}
	},
	"openapi": "3.0.0",
	"paths": {
		"/example/without-generic": {
			"post": {
				"operationId": "WithoutGeneric",
				"responses": {
					"204": {
						"description": "No content"
					}
				},
				"security": [],
				"parameters": [],
				"requestBody": {
					"required": true,
					"content": {
						"application/json": {
							"schema": {
								"$ref": "#/components/schemas/TheRequestBodyWithoutGeneric"
							}
						}
					}
				}
			}
		},
		"/example/with-generic": {
			"post": {
				"operationId": "WithGeneric",
				"responses": {
					"204": {
						"description": "No content"
					}
				},
				"security": [],
				"parameters": [],
				"requestBody": {
					"required": true,
					"content": {
						"application/json": {
							"schema": {
								"$ref": "#/components/schemas/TheRequestBodyWithGeneric_PayloadTypes_"
							}
						}
					}
				}
			}
		}
	},
	"servers": [
		{
			"url": "/"
		}
	]
}
import { Body, Controller, Post, Route } from "tsoa";

interface Payload {
  key: string;
  payload: unknown;
}

export interface ThePayload extends Payload {
  key: "theKey";
  payload: {
    thePayload: string;
  };
}

type PayloadMap<Payloads extends Payload[]> = {
  [Payload in Payloads[number] as Payload["key"]]?: Payload["payload"];
};

type PayloadTypes = [ThePayload];

// Use generic, pass payloads using payload array
export interface TheRequestBodyWithGeneric<Payloads extends Payload[]> {
  payloadData: PayloadMap<Payloads>;
}

// Do not use generic, pass PayloadTypes directly
export interface TheRequestBodyWithoutGeneric {
  payloadData: PayloadMap<PayloadTypes>;
}

@Route("example")
export class ExampleController extends Controller {
  @Post("with-generic")
  public async withGeneric(
    @Body()
    requestBody: TheRequestBodyWithGeneric<PayloadTypes>
  ) {
    console.log(requestBody);
  }

  @Post("without-generic")
  public async withoutGeneric(
    @Body()
    requestBody: TheRequestBodyWithoutGeneric
  ) {
    console.log(requestBody);
  }
}

Current Behavior

The PayloadMap_PayloadTypes_ properties are emtpy.

{
	"components": {
		"examples": {},
		"headers": {},
		"parameters": {},
		"requestBodies": {},
		"responses": {},
		"schemas": {
			"PayloadMap_PayloadTypes_": {
				"properties": {},
				"type": "object"
			},
			"TheRequestBodyWithGeneric_PayloadTypes_": {
				"properties": {
					"payloadData": {
						"$ref": "#/components/schemas/PayloadMap_PayloadTypes_"
					}
				},
				"required": [
					"payloadData"
				],
				"type": "object",
				"additionalProperties": false
			},
			"TheRequestBodyWithoutGeneric": {
				"properties": {
					"payloadData": {
						"$ref": "#/components/schemas/PayloadMap_PayloadTypes_"
					}
				},
				"required": [
					"payloadData"
				],
				"type": "object",
				"additionalProperties": false
			}
		},
		"securitySchemes": {}
	},
	"info": {
		"title": "tsoa",
		"version": "1.0.0",
		"license": {
			"name": "MIT"
		},
		"contact": {}
	},
	"openapi": "3.0.0",
	"paths": {
		"/example/with-generic": {
			"post": {
				"operationId": "WithGeneric",
				"responses": {
					"204": {
						"description": "No content"
					}
				},
				"security": [],
				"parameters": [],
				"requestBody": {
					"required": true,
					"content": {
						"application/json": {
							"schema": {
								"$ref": "#/components/schemas/TheRequestBodyWithGeneric_PayloadTypes_"
							}
						}
					}
				}
			}
		},
		"/example/without-generic": {
			"post": {
				"operationId": "WithoutGeneric",
				"responses": {
					"204": {
						"description": "No content"
					}
				},
				"security": [],
				"parameters": [],
				"requestBody": {
					"required": true,
					"content": {
						"application/json": {
							"schema": {
								"$ref": "#/components/schemas/TheRequestBodyWithoutGeneric"
							}
						}
					}
				}
			}
		}
	},
	"servers": [
		{
			"url": "/"
		}
	]
}

If the declaration of the withoutGeneric and withGeneric methods are switched this will not add typing for the PayloadMap_PayloadTypes_ type. While how it is written now will generate the correct typing.

import { Body, Controller, Post, Route } from "tsoa";

interface Payload {
  key: string;
  payload: unknown;
}

export interface ThePayload extends Payload {
  key: "theKey";
  payload: {
    thePayload: string;
  };
}

type PayloadMap<Payloads extends Payload[]> = {
  [Payload in Payloads[number] as Payload["key"]]?: Payload["payload"];
};

type PayloadTypes = [ThePayload];

// Use generic, pass payloads using payload array
export interface TheRequestBodyWithGeneric<Payloads extends Payload[]> {
  payloadData: PayloadMap<Payloads>;
}

// Do not use generic, pass PayloadTypes directly
export interface TheRequestBodyWithoutGeneric {
  payloadData: PayloadMap<PayloadTypes>;
}

@Route("example")
export class ExampleController extends Controller {
  @Post("without-generic")
  public async withoutGeneric(
    @Body()
    requestBody: TheRequestBodyWithoutGeneric
  ) {
    console.log(requestBody);
  }

  @Post("with-generic")
  public async withGeneric(
    @Body()
    requestBody: TheRequestBodyWithGeneric<PayloadTypes>
  ) {
    console.log(requestBody);
  }
}

Possible Solution

Not sure. Either types should not map to the same name if not 100% equal (the type should be equal here, but there are issues with the generics). However, the real issue here is that nested genreic/mapped types (not sure what the root cause is) are not always correctly generated to types. Semantically the two methods have the same interface, but one method has an extra layer of generics that doesn't play well with tsoa. I've opened a separate issue for this here: #1268

Steps to Reproduce

https://github.com/TimoGlastra/tsoa-error/tree/repro-nested-generics-no-generic (note it's not main branch)

  1. yarn install, yarn build.
  2. Check the generated swagger json (missing types for PayloadMap_PayloadTypes_.)
  3. switch order of methods in exampleController.ts
  4. Check the generated swagger json (has types for PayloadMap_PayloadTypes_.)

Context (Environment)

Version of the library: 4.1.0
Version of NodeJS: 16.13.0

  • Confirm you were using yarn not npm: [x]

Detailed Description

Breaking change?

Not sure, don't know enough about the library to say.

@github-actions
Copy link

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days

@github-actions github-actions bot added the Stale label Jul 19, 2022
@TimoGlastra
Copy link
Author

Still relevant

@github-actions github-actions bot removed the Stale label Jul 20, 2022
@github-actions
Copy link

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days

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