nuxt-open-fetch icon indicating copy to clipboard operation
nuxt-open-fetch copied to clipboard

Type ambiguity with pathParamsAsTypes for overlapping dynamic paths

Open simplg opened this issue 3 months ago • 3 comments

Problem

When using the openapi-typescript option pathParamsAsTypes: true within the nuxt-open-fetch module, a type ambiguity arises for API endpoints that have overlapping dynamic paths.

This issue occurs because TypeScript's type system cannot distinguish between two paths when one is a dynamic substring of the other. For example, consider the following two endpoints:

  • /api/v1/user/{uuid}
  • /api/v1/user/{uuid}/edit

The openapi-typescript generator correctly translates these into the following path types:

  • path: `/api/v1/user/${string}`
  • path: `/api/v1/user/${string}/edit`

From a TypeScript perspective, "/edit" is just a string, which makes the second path type a valid sub-type of the first one. Consequently, when calling the fetch hook, TypeScript infers the wrong path type, leading to a type error when attempting to pass the second path.

This creates a usability problem where developers cannot leverage the full type safety benefits of the pathParamsAsTypes option for such common API structures.

Reproduction

  1. Sample OpenAPI schema (openapi.yaml)
openapi: 3.0.0
info:
  title: User API
  version: 1.0.0
paths:
  /api/v1/user/{uuid}:
    get:
      summary: Get user by UUID
      parameters:
        - name: uuid
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  uuid:
                    type: string
                  login:
                    type: string
        default:
          description: Default error response
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: integer
                  message:
                    type: string
  /api/v1/user/{uuid}/edit:
    put:
      summary: Edit user by UUID
      parameters:
        - name: uuid
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                uuid:
                  type: string
                login:
                  type: string
                password:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: {}
        default:
          description: Default error response
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: integer
                  message:
                    type: string
  1. Generated types (.nuxt/types/open-fetch/schemas/api.ts)
export interface paths {
  [path: `/api/v1/user/${string}`]: {
    get: {
      parameters: {
        query?: never;
        header?: never;
        path: {
          uuid: string;
        };
        cookie?: never;
      };
      requestBody?: never;
      responses: {
        200: {
          headers: {
            [name: string]: unknown;
          };
          content: {
            "application/json": {
              uuid?: string;
              login?: string;
            };
          };
        };
        /** @description Default error response */
        default: {
          headers: {
            [name: string]: unknown;
          };
          content: {
            "application/json": {
              code?: number;
              message?: string;
            };
          };
        };
      };
    };
    put?: never;
    post?: never;
    delete?: never;
    options?: never;
    head?: never;
    patch?: never;
    trace?: never;
  };
  [path: `/api/v1/user/${string}/edit`]: {
    get?: never;
    put: {
      parameters: {
        query?: never;
        header?: never;
        path: {
          uuid: string;
        };
        cookie?: never;
      };
      requestBody: {
        content: {
          "application/json": {
            uuid?: string;
            login?: string;
            password?: string;
          };
        };
      };
      responses: {
        /** @description OK */
        200: {
          headers: {
            [name: string]: unknown;
          };
          content: {
            "application/json": Record<string, never>;
          };
        };
        /** @description Default error response */
        default: {
          headers: {
            [name: string]: unknown;
          };
          content: {
            "application/json": {
              code?: number;
              message?: string;
            };
          };
        };
      };
    };
    post?: never;
    delete?: never;
    options?: never;
    head?: never;
    patch?: never;
    trace?: never;
  };
}
  1. Nuxt composable code (composable/useUser.ts)
import { useNuxtApp } from '#app';
export const useUser = (uuid: string) => {
const { $api } = useNuxtApp();

// This works fine:
const userData = await $api(`/api/v1/user/${uuid}`);

// This fails with a type error:
const updateData = await $api(`/api/v1/user/${uuid}/edit`, {
  method: "PUT",
  body: {
    login: "xxx",
    password: "xxx",
    uuid: "xxx"
  },
});
// TypeScript error: "Argument of type '{ login: string; password: string; uuid: string; }' is not assignable to parameter of type 'undefined'."
// The path type `"/api/v1/user/{uuid}"` is incorrectly matched.

return { userData,  updateData };
}

Workaround

A possible workaround is to define a type alias for string and manually cast the path parameters. This tricks TypeScript into treating the two paths as distinct types.

// .nuxt/types/open-fetch/schemas/api.ts (manual addition)
type APIString = string;

// Replace all ${string} with ${APIString} with .replace before writing the ts file.

Proposed solution

t would be highly beneficial to add a new option to the module's configuration that automates this workaround. This would simplify the developer experience and make the pathParamsAsTypes option more robust.

I propose a new configuration option, for example: pathParamTypeAlias.

openFetch: {
    openAPITS: {
      pathParamsAsTypes: true,
    },
    pathParamTypeAlias: 'APIString',
    clients: {
      api: {
        baseURL: "https://api.example.com",
        schema: "https://api.example.com/openapi.yaml",
      },
    },
  },

When this option is enabled, the module would internally replace all ${string} path types generated by openapi-typescript with the specified alias. This would allow developers to use the typed paths seamlessly without running into type ambiguity. The downside of this approach is that you need to cast every string in the path as the type alias specified (here "APIString").

So for example the above now working code would then be:

import { useNuxtApp } from '#app';
import type { APIString } from "#open-fetch";
export const useUser = (uuid: string) => {
const { $api } = useNuxtApp();

// This works fine:
const userData = await $api(`/api/v1/user/${uuid as APIString}`);

// This would now work
const updateData = await $api(`/api/v1/user/${uuid as APIString}/edit`, {
  method: "PUT",
  body: {
    login: "xxx",
    password: "xxx",
    uuid: "xxx"
  },
});

return { userData,  updateData };
}

Additional information

  • [x] Would you be willing to help implement this feature?

Final checks

simplg avatar Aug 18 '25 20:08 simplg

Thanks for noting this! After writing this comment, I’ve been thinking about fully supporting the pathParamsAsTypes option.

We could try to hotfix this locally, but it might make more sense to report it to the openapi-typescript project. That way, we can get their feedback and implement the solution at the source.

I’m not a huge fan of your current solution because it requires casting all path string parameters, which is the most common parameter type (since there’s no uuid). Fixing this edge case requires adding annoying behaviour for all use cases.

I haven’t had much time to dig, but there might be another approach. People are doing some wild things with TypeScript template literal types. For instance, we know path parameters can’t contain /, so maybe there’s a way to split the type accordingly. I’ve tried a bunch of things but I haven't managed to get it to work yet.

Norbiros avatar Aug 26 '25 18:08 Norbiros

Yes I understand your POV and I do agree it can be a huge downside. In our codebase, we are using a helper function instead of manually typing "as" which does the casting but it is still not pretty.

For openapi-typescript, I found this issue which is worth tracking https://github.com/openapi-ts/openapi-typescript/issues/1752 .

simplg avatar Sep 15 '25 14:09 simplg

For now, I could try adding Nuxt/Nitro hooks so you can easily modify the generated types file and implement your temporary hotfix. Would it solve your issue for now?

Norbiros avatar Sep 15 '25 14:09 Norbiros