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

[BUG][typescript-*] Bad behavior when trying to map generics

Open allejo opened this issue 7 months ago • 2 comments

Bug Report Checklist

  • [x] Have you provided a full/minimal spec to reproduce the issue?
  • [x] Have you validated the input using an OpenAPI validator?
  • [x] Have you tested with the latest master to confirm the issue still exists?
  • [x] Have you searched for related issues/PRs?
  • [x] What's the actual output vs expected output?
  • [ ] [Optional] Sponsorship to speed up the bug fix or feature request (example)
Description

I have an OpenAPI file where one of the components has a data type of "object." In TypeScript, the object data type is very generic and doesn't help with data typing but I do think it's an appropriate default for most cases. However, for my use case on a particular project, I would like to map object to Record<string, unknown> but this leads to generation problems.

The first problem is that Record is a built-in type, so there's no need to import it. This results in bad imports being generated.

import type { Recordstringunknown } from './Recordstringunknown';
import {
    RecordstringunknownFromJSON,
    RecordstringunknownFromJSONTyped,
    RecordstringunknownToJSON,
    RecordstringunknownToJSONTyped,
} from './Recordstringunknown';

The second problem (also seen in the import above) is that the <> for generics are stripped out of what's generated.

export interface User {
    // ...

    /**
     * 
     * @type {Recordstringunknown}
     * @memberof User
     */
    metadata?: Recordstringunknown;
}

The above generated code should be Record<string,unknown> as specified in the config.yml file I have listed below. Related also, even though my coding style preference is to have a space after the comma (i.e., Record<string, unknown>), that leads to even worse generated results.

Note: the Java template can handle the <> (generic syntax) and spaces without issue.

import type { Recordstring unknown } from './Recordstring unknown';
import {
    Recordstring unknownFromJSON,
    Recordstring unknownFromJSONTyped,
    Recordstring unknownToJSON,
    Recordstring unknownToJSONTyped,
} from './Recordstring unknown';

// ...

export interface User {
    // ...

    /**
     * 
     * @type {Recordstring unknown}
     * @memberof User
     */
    metadata?: Recordstring unknown;
}
openapi-generator version

7.13.0

OpenAPI declaration file content or url
openapi: 3.1.0
info:
  title: Sample API
  description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML.
  version: 1.0.0

servers:
  - url: http://api.example.com/v1
    description: Optional server description, e.g. Main (production) server
  - url: http://staging-api.example.com
    description: Optional server description, e.g. Internal staging server for testing

paths:
  /users:
    get:
      summary: Returns a list of users.
      description: Optional extended description in CommonMark or HTML.
      responses:
        "200": # status code
          description: A JSON array of user names
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          format: int64
          description: The user ID.
        name:
          type: string
          description: The user name.
        email:
          type: string
          format: email
          description: The user email address.
        metadata:
          type: object
Generation Details
# config.yml

typeMappings:
 object: Record<string,unknown>
Steps to reproduce
docker run --rm \
  -v ".:/local" \
  openapitools/openapi-generator-cli:latest generate \
  -c "/local/config.yml" \
  -g "typescript-fetch" \
  -i "/local/openapi.yml" \
  -o /local/src
Related issues/PRs

None that I've been able to find.

Suggest a fix

I believe this is a problem with the typescript-* templates; I've tested this with typescript-fetch and typescript-axios and both have the same problem. But if I switch the template to java, it correctly handles generics/spaces

allejo avatar May 22 '25 18:05 allejo

@joscha @mkusaka , hello, please let me know what you think of this: https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type I want to add these all of types, but without the <>, to languageSpecificPrimitives: https://github.com/OpenAPITools/openapi-generator/blob/12fa2c0032a12c29efd8cf359e1e62cfdc0f0d1a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractTypeScriptClientCodegen.java#L307 And override the defaultIncludes property in the same fashion: https://github.com/OpenAPITools/openapi-generator/blob/12fa2c0032a12c29efd8cf359e1e62cfdc0f0d1a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java#L1735 And then over here: https://github.com/OpenAPITools/openapi-generator/blob/12fa2c0032a12c29efd8cf359e1e62cfdc0f0d1a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractTypeScriptClientCodegen.java#L808 I'll add an else-if to match the the substring of type before the < This should take care of the import issue and the name sanitization issue. Also, @allejo

Note: the Java template can handle the <> (generic syntax) and spaces without issue.

Please can you confirm something? I see that even though the type name worked as expected, there is still a funny-looking import statement:

import org.openapitools.client.model.Record&lt;string,unknown&gt;;

DavidGrath avatar Jun 12 '25 08:06 DavidGrath

As an update, I've done and tested it and gotten seemingly satisfactory results:

import { mapValues } from '../runtime';
/**
 * 
 * @export
 * @interface User
 */
export interface User {
    /**
     * The user ID.
     * @type {number}
     * @memberof User
     */
    id?: number;
    /**
     * The user name.
     * @type {string}
     * @memberof User
     */
    name?: string;
    /**
     * The user email address.
     * @type {string}
     * @memberof User
     */
    email?: string;
    /**
     * 
     * @type {Record<string, unknown>}
     * @memberof User
     */
    metadata?: Record<string, unknown>;
}

/**
 * Check if a given object implements the User interface.
 */
export function instanceOfUser(value: object): value is User {
    return true;
}

export function UserFromJSON(json: any): User {
    return UserFromJSONTyped(json, false);
}

export function UserFromJSONTyped(json: any, ignoreDiscriminator: boolean): User {
    if (json == null) {
        return json;
    }
    return {
        
        'id': json['id'] == null ? undefined : json['id'],
        'name': json['name'] == null ? undefined : json['name'],
        'email': json['email'] == null ? undefined : json['email'],
        'metadata': json['metadata'] == null ? undefined : json['metadata'],
    };
}

export function UserToJSON(json: any): User {
    return UserToJSONTyped(json, false);
}

export function UserToJSONTyped(value?: User | null, ignoreDiscriminator: boolean = false): any {
    if (value == null) {
        return value;
    }

    return {
        
        'id': value['id'],
        'name': value['name'],
        'email': value['email'],
        'metadata': value['metadata'],
    };
}

I contemplated the potential use of other utility types and tried out Pick on a new schema object, UserSummary, which filters out email from User. It's a "top-level" schema compared to metadata, so I ran into an issue and the only way I've gotten it to work so far is to use withoutRuntimeChecks: true

import * as runtime from '../runtime';
import type {
  User,
} from '../models/index';

/**
 * 
 */
export class DefaultApi extends runtime.BaseAPI {

    /**
     * Optional extended description in CommonMark or HTML.
     * Returns a list of users.
     */
    async usersGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<User>> {
        const queryParameters: any = {};

        const headerParameters: runtime.HTTPHeaders = {};

        const response = await this.request({
            path: `/users`,
            method: 'GET',
            headers: headerParameters,
            query: queryParameters,
        }, initOverrides);

        return new runtime.JSONApiResponse(response);
    }

    /**
     * Optional extended description in CommonMark or HTML.
     * Returns a list of users.
     */
    async usersGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<User> {
        const response = await this.usersGetRaw(initOverrides);
        return await response.value();
    }

    /**
     * Optional extended description in CommonMark or HTML.
     * Returns a list of users.
     */
    async usersSummaryGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Pick<User, "email">>> {
        const queryParameters: any = {};

        const headerParameters: runtime.HTTPHeaders = {};

        const response = await this.request({
            path: `/users/summary`,
            method: 'GET',
            headers: headerParameters,
            query: queryParameters,
        }, initOverrides);

        return new runtime.JSONApiResponse(response);
    }

    /**
     * Optional extended description in CommonMark or HTML.
     * Returns a list of users.
     */
    async usersSummaryGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Pick<User, "email">> {
        const response = await this.usersSummaryGetRaw(initOverrides);
        return await response.value();
    }

}

I'll make the PR over this weekend, but I'd like to receive feedback if anyone has any

DavidGrath avatar Jun 12 '25 22:06 DavidGrath