ecyrbe / zodios

typescript http client and server with zod validation
https://www.zodios.org/
MIT License
1.66k stars 45 forks source link

Stronger typing on `makeApi` #425

Open andenacitelli opened 1 year ago

andenacitelli commented 1 year ago

Just took me about twenty minutes to figure out what was wrong with this:

const commonParameters = parametersBuilder()
  .addHeader("authorization", z.string().min(1))
  .addHeader("email", z.string().email());

const usersApi = makeApi([
  {
    method: "post",
    path: "/",
    response: z.undefined(),
    params: commonParameters.build(),
  },
  {
    method: "get",
    path: "/",
    response: UserSchema,
    params: commonParameters.build(),
  },
  {
    method: "put",
    path: "/",
    response: UserSchema,
    params: commonParameters.addBody(UserCreateInputSchema).build(),
  },
  {
    method: "delete",
    path: "/",
    response: z.undefined(),
    params: commonParameters.build(),
  },
]);

The issue is that params is supposed to be parameters. I didn't have any kind of error pop up in my editor. Is it possible to more strongly type this so that it gives you an error in your editor, or does TypeScript just not allow this? Or is something in my editor just messed up?

And sidenote, love the project! Coupled with zod-prisma-types, this is a much quicker-to-prototype alternative to OpenAPI that also doesn't require a code generation step and tends to integrate a bit more smoothly with Prisma. Has the same disadvantage as tRPC where it couples you to TypeScript, but I honestly feel like that's an advantage for smaller projects.

ecyrbe commented 1 year ago

Hello @aacitelli,

This is due to typescript allowing unknown properties on functions (function matching is contravariant, while here we would like it to be covariant), check this: image

ecyrbe commented 1 year ago

in zodios v11 you'll be able to use as const and satisfies together as a workaround: image

ecyrbe commented 1 year ago

If anyone has a solution to this, i'll keep this open in case someone has a working idea

cloudbring commented 1 year ago

Thanks to this issue, it solved a problem I have been having with params instead of parameters and using parametersBuilder() and when using that const, using .build() when setting it on the apiBuilder parameter member.

Example below:

const movieParams = parametersBuilder().addParameters('Query', {
  limit: z.number().optional().default(1000),
  offset: z.number().optional().default(0),
  page: z.number().optional().default(1),
  _id: z.string().optional(),
});

export const movieApi = apiBuilder({
  method: 'get',
  path: '/movie',
  alias: 'getMovies',
  description: 'Get all movies',
  parameters: movieParams.build(),
  response: movieResponse,
}).build();

This got the type signatures to finally calm down and not keep giving me TS errors.

Dimava commented 1 year ago

edit: this doesn't work, nevermind https://tsplay.dev/WYllxm

interface Endpoint {
  method: "get" | "post" | "put" | "patch" | "delete";
  path: string;
  response: z.ZodTypeAny;
  parameters?: Array<{
    name: string;
    type: "Query" | "Path" | "Body" | "Header";
    schema: z.ZodTypeAny;
  }>;
};
function makeEndpoint<T extends Endpoint>(api: {[K in keyof Endpoint]: T[K]}) {
  return api;
}
function myMakeApi<const T extends unknown[]>(api: { [K in keyof T]: ZodiosEndpointDefinition<T[K]> }) {
  return api
}

seems to be able to do the thing (if I understood the problem correctly) If you ever need extra things on Endpoint you may extend the interface anyways

Dimava commented 1 year ago
declare function makeApi<Api extends ZodiosEndpointDefinitions>(
    api: Narrow<Api>
        // & NoInfer<
            & readonly ZodiosEndpointDefinition<any>[]
            & readonly {[K in keyof Api[0] as K extends X ? never: K]: never}[]
        // >
    ): Api;
type X = Extract<keyof ZodiosEndpointDefinition, string>
type NoInfer<T> = [T][T extends any ? 0 : never]
interface ZodiosEndpointDefinition<R = unknown> { /*...*/ } // to make it extandable for custom keys

is a minimum requirement to have key autocompletion and key exclusion Problems: it's not [A, B], is't (A | B)[]

edit: const in template is not needed, forgot to remove after testing it

ecyrbe commented 1 year ago

there are some tricks out there to do some kind of strict type matching, but i failed to make them work with type narrowing + tuples. My only hope is that typescript will add a statisfies keyword for generics like they did for as const. So far the opened issue for this has a negative feedback from typescript team that don't want to implement it. We need to change their mind somehow

Dimava commented 1 year ago
type UpTo<N extends number, A extends number[] = []> = 
| number extends N ? number
: A['length'] extends N ? A[number]
: UpTo<N, [...A, A['length']]>;

type Proper<T> =
 & {[K in keyof T | X]?: K extends X ? unknown: never}
 & T;

type Foo<Api extends any[]> = NoInfer<{ [K in UpTo<99>]?: Proper<Api[K]> }>
declare function makeApi<Api extends ZodiosEndpointDefinitions>(
    api: 
        Narrow<Api> & Foo<Api>
    ): Api;
type X = Extract<keyof ZodiosEndpointDefinition, string>
type NoInfer<T> = [T][T extends any ? 0 : never]

Somehow (Magic🌈™️ ) works with tuples

ecyrbe commented 1 year ago

Nice trick, unfortunately this will impact perf big time. Also we have tuples in objects in tuples in the definition which make this even harder and slower if we where to use something like this :(

Dimava commented 1 year ago

I may try ArkType -inspired validator approach

Do you have a benchmark?


Why is makeApi api: Item[] and not api: Record<alias, Item> by the way ?