tsoa icon indicating copy to clipboard operation
tsoa copied to clipboard

Nested generic with mapped types do not work

Open TimoGlastra opened this issue 2 years ago • 4 comments

When using nested generics with mapped types, tsoa can't correctly infer the type of the nested payloads. If a layer of passing generics is removed, it will work.

Sorting

  • I'm submitting a ...

    • [x] bug report
    • [ ] feature request
    • [ ] support request
  • I confirm that I

    • [x] used the search to make sure that a similar issue hasn't already been submit

Expected Behavior

I would expect tsoa to follow multiple layers of nested/generic/mapped types. The interface with extra generics layer is semnatically the same as the one without, but produce different results.

Notice the precense of the PayloadMap_PayloadTypes_ in the json below

{
    "components": {
        "examples": {},
        "headers": {},
        "parameters": {},
        "requestBodies": {},
        "responses": {},
        "schemas": {
            "PayloadMap_PayloadTypes_": {
                "properties": {
                    "theKey": {
                        "properties": {
                            "thePayload": {
                                "type": "string"
                            }
                        },
                        "required": [
                            "thePayload"
                        ],
                        "type": "object"
                    }
                },
                "type": "object"
            },
            "TheRequestBody": {
                "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": {
            "post": {
                "operationId": "Method",
                "responses": {
                    "204": {
                        "description": "No content"
                    }
                },
                "security": [],
                "parameters": [],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/TheRequestBody"
                            }
                        }
                    }
                }
            }
        }
    },
    "servers": [
        {
            "url": "/"
        }
    ]
}

I would expect to use the TheRequestBody with the generic.

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];

// To make this work, remove the `Payloads` generic and pass `PayloadTypes` directly
// to the `PayloadMap` generic type.
export interface TheRequestBody<Payloads extends Payload[]> {
  payloadData: PayloadMap<Payloads>;
}

@Route("example")
export class ExampleController extends Controller {
  @Post()
  public async method(
    @Body()
    requestBody: TheRequestBody<PayloadTypes>
  ) {
    console.log(requestBody);
  }
}

Current Behavior

Notice the missing of the PayloadMap_PayloadTypes_ properties.

{
    "components": {
        "examples": {},
        "headers": {},
        "parameters": {},
        "requestBodies": {},
        "responses": {},
        "schemas": {
            "PayloadMap_PayloadTypes_": {
                "properties": {},
                "type": "object"
            },
            "TheRequestBody_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": {
            "post": {
                "operationId": "Method",
                "responses": {
                    "204": {
                        "description": "No content"
                    }
                },
                "security": [],
                "parameters": [],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/TheRequestBody_PayloadTypes_"
                            }
                        }
                    }
                }
            }
        }
    },
    "servers": [
        {
            "url": "/"
        }
    ]
}

To make it work I had to pass the PayloadTypes directly inside the TheRequestBody interface, instead of being able to pass it as a generic (as I used in the expected behaviour above).

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];

export interface TheRequestBody {
  payloadData: PayloadMap<PayloadTypes>;
}

@Route("example")
export class ExampleController extends Controller {
  @Post()
  public async method(
    @Body()
    requestBody: TheRequestBody
  ) {
    console.log(requestBody);
  }
}

Possible Solution

Not sure how this works under the hood so can't provide a solutins, but it seems to me it has something to do with nesting of generics that is not being inferred properly

Steps to Reproduce

https://github.com/TimoGlastra/tsoa-error/tree/repro-nested-generics-mapped-types (note not main branch)

  1. yarn install, yarn build
  2. See swagger.json with missing type for PayloadMap_PayloadTypes_
  3. Update exampleController with changes from actual behaviour code (so directly pass PayloadTypes in TheRequestBody
  4. See swagger.json with correct type 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?

TimoGlastra avatar Jun 18 '22 19:06 TimoGlastra

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[bot] avatar Jul 19 '22 00:07 github-actions[bot]

Still relevant

TimoGlastra avatar Jul 19 '22 08:07 TimoGlastra

I also have the same issue.

shashank42 avatar Jul 26 '22 16:07 shashank42

Please feel free to send a PR

WoH avatar Jul 28 '22 06:07 WoH