middleware icon indicating copy to clipboard operation
middleware copied to clipboard

[zod-openapi] `tsc` error TS2345 when using a `query` input data

Open 0237h opened this issue 2 years ago • 7 comments

Hi team, I encountered this issue when trying to build an API with more than one input type (e.g. PATH + QUERY like /{path}?q=xxx).

When using zod-openapi with a route including query input data with (or without) params, the type resolution fails when trying to access the input from the handler.

In this context (the sample file is provided at the end of this post)

app.openapi(route, async (c) => {
    const { paramValue } = c.req.valid('param') as ParamSchema;
    const { queryValue } = c.req.valid('query') as QuerySchema;
    ...
});

the calls to c.req.valid will produce the following errors at compile time:

src/min.ts:43:40 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'never'.                                                 

43     const { paramValue } = c.req.valid('param') as ParamSchema;
                                          ~~~~~~~                           

src/min.ts:44:40 - error [tsc.trace.both.gz](https://github.com/honojs/middleware/files/12908484/tsc.trace.both.gz): Argument of type 'string' is not assignable to parameter of type 'never'.

44     const { queryValue } = c.req.valid('query') as QuerySchema;
                                          ~~~~~~~                   


Found 2 errors in the same file, starting at: src/min.ts:43

that is the output of bun run tsc --noEmit --pretty --skipLibCheck --strict src/min.ts

Note that the problem disappears if we just use the param as input:

const route = createRoute({
    method: 'get',
    path: '/{paramValue}/path',
    request: {
        params: ParamSchema,
        // query: QuerySchema,
    },
    ...
});

app.openapi(route, async (c) => {
    const { paramValue } = c.req.valid('param') as ParamSchema; // Works !
    ...
});

But not when commenting out the param and leaving just the query:

const route = createRoute({
    method: 'get',
    path: '/path', // Note the {param} is removed from the path
    request: {
        //params: ParamSchema,
        query: QuerySchema,
    },
    ...
});

const app = new OpenAPIHono();

app.openapi(route, async (c) => {
    // error TS2345: Argument of type 'string' is not assignable to parameter of type 'never'.
    const { queryValue } = c.req.valid('query') as QuerySchema;
    ...
});

Build traces

Looking at the build traces from tsc, we can see for param the display type is correct <T extends \"param\">(target: T) ... allowing to proceed without any errors.

Param only

{"id":15461,"symbolName":"valid","recursionId":3796,"firstDeclaration":{"path":"/home/user/Documents/substreams-clock-api/node_modules/hono/dist/types/request.d.ts","start":{"line":57,"character":71},"end":{"line":58,"character":94}},"flags":["Object"],"display":"<T extends \"param\">(target: T) => InputToDataByTarget<{ param: { paramValue: \"a\" | \"b\" | \"c\"; }; }, T>"},

However in the other cases, it resolves to <T extends never>(target: T) ... making it impossible to infer the type.

Query only

{"id":15453,"symbolName":"valid","recursionId":3793,"firstDeclaration":{"path":"/home/user/Documents/substreams-clock-api/node_modules/hono/dist/types/request.d.ts","start":{"line":57,"character":71},"end":{"line":58,"character":94}},"flags":["Object"],"display":"<T extends never>(target: T) => InputToDataByTarget<undefined, T> | InputToDataByTarget<Partial<{ json: unknown; form: unknown; query: unknown; queries: unknown; param: unknown; header: unknown; cookie: unknown; }>, T>"},

Both

{"id":15472,"symbolName":"valid","recursionId":3803,"firstDeclaration":{"path":"/home/user/Documents/substreams-clock-api/node_modules/hono/dist/types/request.d.ts","start":{"line":57,"character":71},"end":{"line":58,"character":94}},"flags":["Object"],"display":"<T extends never>(target: T) => InputToDataByTarget<undefined, T> | InputToDataByTarget<Partial<{ json: unknown; form: unknown; query: unknown; queries: unknown; param: unknown; header: unknown; cookie: unknown; }>, T>"},

The full traces for each are available here: tsc.traces.tar.gz

Related

In #77, the problem appeared to be fixed by using strict: true for the config and upgrading to the latest versions of hono and @hono/zod-validator. This doesn't seem to fix the problem in this case.

System information

Bun version 1.0.2

bun pm ls

node_modules (16)
├── @hono/[email protected]
├── @sinclair/[email protected]
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
└── [email protected]

Sample file

import { OpenAPIHono, z, createRoute } from '@hono/zod-openapi';
import { TypedResponse } from 'hono';

const ParamSchema = z.object({
    paramValue: z.enum(['a', 'b', 'c'])
    .openapi({
        param: {
            name: 'paramValue',
            in: 'path',
        }
    })
});
type ParamSchema = z.infer<typeof ParamSchema>;

const QuerySchema = z.object({
    queryValue: z.coerce.number()
    .openapi({
        param: {
            name: 'queryValue',
            in: 'query',
        }
    })
});
type QuerySchema = z.infer<typeof QuerySchema>;

const route = createRoute({
    method: 'get',
    path: '/{paramValue}/path',
    request: {
        params: ParamSchema,
        query: QuerySchema,
    },
    responses: {
        200: {
            description: 'Sample endpoint',
        },
    },
});

const app = new OpenAPIHono();

app.openapi(route, async (c) => {
    const { paramValue } = c.req.valid('param') as ParamSchema;
    const { queryValue } = c.req.valid('query') as QuerySchema;

    return {
        response: c.text("Not working...")
    } as TypedResponse<string>;
});

export default app;

0237h avatar Oct 15 '23 02:10 0237h

Hi @Krow10,

Apologies for the delayed response.

We can only use the string type for a query, so you won't be able to use z.coerce.number(). Instead, you could use something like transform().

yusukebe avatar Oct 20 '23 21:10 yusukebe

Hi @yusukebe, no worries thanks for the response.

Not sure I follow, doesn't coerce aim to treat the query as string and convert it by itself ? If I understand correctly, you propose to declare first as z.string() and use transform for parsing to a number how would that be different than coerce ?

0237h avatar Oct 20 '23 23:10 0237h

Not sure I follow, doesn't coerce aim to treat the query as a string and convert it by itself?

Ideally, it should do it, but not. Also the input type will be number.

Screenshot 2023-10-21 at 19 15 39 Screenshot 2023-10-21 at 19 15 44

If I understand correctly, you propose to first declare as z.string() and use transform for parsing to a number. How would that be different from coerce?

Yes. For example, you can write as follows:

const schema = z.object({
  numericValue: z.string().transform((val) => {
    const parsed = parseInt(val)
    if (isNaN(parsed)) {
      return z.NEVER
    }
    // validate `parsed`
    // ...
    return parsed
  }),
})

yusukebe avatar Oct 21 '23 10:10 yusukebe

A bit late to respond !

Thank you for the example, looks like the proper way to parse it then. Maybe one last question then: in what context would you use coerce without the need for additional transforms ?

The issue can probably be closed now, thanks again.

0237h avatar Oct 27 '23 21:10 0237h

Hi,

Maybe one last question then: in what context would you use coerce without the need for additional transforms?

coerce in Zod is used when we want to coerce value types, as the naming suggests.

In the case of Zod OpenAPI or Zod Validator of Hono, there isn't a chance to use coerce. Using coerce implies that the output type is explicit, e.g., String or Number, but it does not know the type of the "input".

In Zod OpenAPI or Zod Validator, the input value of a query should be string because the values are always string, although JSON can have number or boolean values. So, if you use coerce, it does not know the input type, and therefore it throws an error.

yusukebe avatar Oct 28 '23 01:10 yusukebe

I have few experience with zod. I managed to do validate the string query param as a number and also parse it as a number this way:

z.object({
    queryValue: z.string().default('10').pipe(z.coerce.number()),
});

That way, zValidator doesn't complain.

jdavidferreira avatar Nov 21 '23 17:11 jdavidferreira