From 6b2a90815924b2dbe6f0e948489ff5379f2219a3 Mon Sep 17 00:00:00 2001 From: Nick Graef <1031317+ngraef@users.noreply.github.com> Date: Fri, 9 Aug 2024 13:17:01 -0600 Subject: [PATCH 1/2] fix identification of required properties when `strictNullChecks` is disabled # Conflicts: # packages/openapi-fetch/src/index.d.ts --- .changeset/lazy-dancers-push.md | 7 +++++ packages/openapi-fetch/src/index.d.ts | 11 ++++--- packages/openapi-react-query/src/index.ts | 6 ++-- .../openapi-typescript-helpers/index.d.ts | 31 ++++++++++++++++--- 4 files changed, 42 insertions(+), 13 deletions(-) create mode 100644 .changeset/lazy-dancers-push.md diff --git a/.changeset/lazy-dancers-push.md b/.changeset/lazy-dancers-push.md new file mode 100644 index 000000000..a3675a91b --- /dev/null +++ b/.changeset/lazy-dancers-push.md @@ -0,0 +1,7 @@ +--- +"openapi-typescript-helpers": patch +"openapi-react-query": patch +"openapi-fetch": patch +--- + +Fix identification of required properties when `strictNullChecks` is disabled diff --git a/packages/openapi-fetch/src/index.d.ts b/packages/openapi-fetch/src/index.d.ts index 3ba091c69..ca173c1a2 100644 --- a/packages/openapi-fetch/src/index.d.ts +++ b/packages/openapi-fetch/src/index.d.ts @@ -1,12 +1,13 @@ import type { ErrorResponse, FilterKeys, - HasRequiredKeys, HttpMethod, + IsOperationRequestBodyOptional, MediaType, OperationRequestBodyContent, PathsWithMethod, ResponseObjectMap, + RequiredKeysOf, SuccessResponse, } from "openapi-typescript-helpers"; @@ -82,14 +83,14 @@ export interface DefaultParamsOption { export type ParamsOption = T extends { parameters: any; } - ? HasRequiredKeys extends never + ? RequiredKeysOf extends never ? { params?: T["parameters"] } : { params: T["parameters"] } : DefaultParamsOption; export type RequestBodyOption = OperationRequestBodyContent extends never ? { body?: never } - : undefined extends OperationRequestBodyContent + : IsOperationRequestBodyOptional extends true ? { body?: OperationRequestBodyContent } : { body: OperationRequestBodyContent }; @@ -150,7 +151,7 @@ export interface Middleware { } /** This type helper makes the 2nd function param required if params/requestBody are required; otherwise, optional */ -export type MaybeOptionalInit, Location extends keyof Params> = HasRequiredKeys< +export type MaybeOptionalInit, Location extends keyof Params> = RequiredKeysOf< FetchOptions> > extends never ? FetchOptions> | undefined @@ -160,7 +161,7 @@ export type MaybeOptionalInit, Location ex // - Determines if the param is optional or not. // - Performs arbitrary [key: string] addition. // Note: the addition It MUST happen after all the inference happens (otherwise TS can’t infer if init is required or not). -type InitParam = HasRequiredKeys extends never +type InitParam = RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?] : [Init & { [key: string]: unknown }]; diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 3e860a929..438c7aa94 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -11,7 +11,7 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient } from "openapi-fetch"; -import type { HasRequiredKeys, HttpMethod, MediaType, PathsWithMethod } from "openapi-typescript-helpers"; +import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers"; export type UseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, @@ -22,7 +22,7 @@ export type UseQueryMethod>, >( method: Method, url: Path, - ...[init, options, queryClient]: HasRequiredKeys extends never + ...[init, options, queryClient]: RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?, Options?, QueryClient?] : [Init & { [key: string]: unknown }, Options?, QueryClient?] ) => UseQueryResult; @@ -36,7 +36,7 @@ export type UseSuspenseQueryMethod( method: Method, url: Path, - ...[init, options, queryClient]: HasRequiredKeys extends never + ...[init, options, queryClient]: RequiredKeysOf extends never ? [(Init & { [key: string]: unknown })?, Options?, QueryClient?] : [Init & { [key: string]: unknown }, Options?, QueryClient?] ) => UseSuspenseQueryResult; diff --git a/packages/openapi-typescript-helpers/index.d.ts b/packages/openapi-typescript-helpers/index.d.ts index edd96dabe..fad9b4ca6 100644 --- a/packages/openapi-typescript-helpers/index.d.ts +++ b/packages/openapi-typescript-helpers/index.d.ts @@ -97,11 +97,17 @@ export type ResponseObjectMap = T extends { responses: any } ? T["responses"] /** Return `content` for a Response Object */ export type ResponseContent = T extends { content: any } ? T["content"] : unknown; -/** Return `requestBody` for an Operation Object */ -export type OperationRequestBody = T extends { requestBody?: any } ? T["requestBody"] : never; +/** Return type of `requestBody` for an Operation Object */ +export type OperationRequestBody = "requestBody" extends keyof T ? T["requestBody"] : never; + +/** Internal helper to get object type with only the `requestBody` property */ +type PickRequestBody = "requestBody" extends keyof T ? Pick : never; + +/** Resolve to `true` if request body is optional, else `false` */ +export type IsOperationRequestBodyOptional = RequiredKeysOf> extends never ? true : false; /** Internal helper used in OperationRequestBodyContent */ -export type OperationRequestBodyMediaContent = undefined extends OperationRequestBody +export type OperationRequestBodyMediaContent = IsOperationRequestBodyOptional extends true ? ResponseContent>> | undefined : ResponseContent>; @@ -152,7 +158,22 @@ export type GetValueWithDefault = Obj extends any export type MediaType = `${string}/${string}`; /** Return any media type containing "json" (works for "application/json", "application/vnd.api+json", "application/vnd.oai.openapi+json") */ export type JSONLike = FilterKeys; -/** Filter objects that have required keys */ + +/** + * Filter objects that have required keys + * @deprecated Use `RequiredKeysOf` instead + */ export type FindRequiredKeys = K extends unknown ? (undefined extends T[K] ? never : K) : K; -/** Does this object contain required keys? */ +/** + * Does this object contain required keys? + * @deprecated Use `RequiredKeysOf` instead + */ export type HasRequiredKeys = FindRequiredKeys; + +/** Helper to get the required keys of an object. If no keys are required, will be `undefined` with strictNullChecks enabled, else `never` */ +type RequiredKeysOfHelper = { + // biome-ignore lint/complexity/noBannedTypes: `{}` is necessary here + [K in keyof T]: {} extends Pick ? never : K; +}[keyof T]; +/** Get the required keys of an object, or `never` if no keys are required */ +export type RequiredKeysOf = RequiredKeysOfHelper extends undefined ? never : RequiredKeysOfHelper; From ea2c26bbeeb4f071f2756567bba6f7c4e409f460 Mon Sep 17 00:00:00 2001 From: Nick Graef <1031317+ngraef@users.noreply.github.com> Date: Fri, 9 Aug 2024 13:20:46 -0600 Subject: [PATCH 2/2] test some scenarios with `strictNullChecks` disabled --- packages/openapi-fetch/package.json | 1 + .../noStrictNullChecks.test.ts | 126 ++++++++++++++++++ .../test/no-strict-null-checks/tsconfig.json | 8 ++ packages/openapi-fetch/tsconfig.json | 2 +- 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 packages/openapi-fetch/test/no-strict-null-checks/noStrictNullChecks.test.ts create mode 100644 packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json diff --git a/packages/openapi-fetch/package.json b/packages/openapi-fetch/package.json index 2c276271f..898b851f6 100644 --- a/packages/openapi-fetch/package.json +++ b/packages/openapi-fetch/package.json @@ -59,6 +59,7 @@ "test": "pnpm run \"/^test:/\"", "test:js": "vitest run", "test:ts": "tsc --noEmit", + "test:ts-no-strict": "tsc --noEmit -p test/no-strict-null-checks/tsconfig.json", "test-e2e": "playwright test", "e2e-vite-build": "vite build test/fixtures/e2e", "e2e-vite-start": "vite preview test/fixtures/e2e", diff --git a/packages/openapi-fetch/test/no-strict-null-checks/noStrictNullChecks.test.ts b/packages/openapi-fetch/test/no-strict-null-checks/noStrictNullChecks.test.ts new file mode 100644 index 000000000..d61ff6615 --- /dev/null +++ b/packages/openapi-fetch/test/no-strict-null-checks/noStrictNullChecks.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, it } from "vitest"; +import createClient from "../../src/index.js"; +import { server, baseUrl, useMockRequestHandler } from "../fixtures/mock-server.js"; +import type { paths } from "../fixtures/api.js"; + +beforeAll(() => { + server.listen({ + onUnhandledRequest: (request) => { + throw new Error(`No request handler found for ${request.method} ${request.url}`); + }, + }); +}); + +afterEach(() => server.resetHandlers()); + +afterAll(() => server.close()); + +describe("client", () => { + describe("TypeScript checks", () => { + describe("params", () => { + it("is optional if no parameters are defined", async () => { + const client = createClient({ + baseUrl, + }); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/self", + status: 200, + body: { message: "OK" }, + }); + + // assert no type error + await client.GET("/self"); + + // assert no type error with empty params + await client.GET("/self", { params: {} }); + }); + + it("checks types of optional params", async () => { + const client = createClient({ + baseUrl, + }); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/self", + status: 200, + body: { message: "OK" }, + }); + + // assert no type error with no params + await client.GET("/blogposts"); + + // assert no type error with empty params + await client.GET("/blogposts", { params: {} }); + + // expect error on incorrect param type + // @ts-expect-error + await client.GET("/blogposts", { params: { query: { published: "yes" } } }); + + // expect error on extra params + // @ts-expect-error + await client.GET("/blogposts", { params: { query: { fake: true } } }); + }); + }); + + describe("body", () => { + it("requires necessary requestBodies", async () => { + const client = createClient({ baseUrl }); + + useMockRequestHandler({ + baseUrl, + method: "put", + path: "/blogposts", + }); + + // expect error on missing `body` + // @ts-expect-error + await client.PUT("/blogposts"); + + // expect error on missing fields + // @ts-expect-error + await client.PUT("/blogposts", { body: { title: "Foo" } }); + + // (no error) + await client.PUT("/blogposts", { + body: { + title: "Foo", + body: "Bar", + publish_date: new Date("2023-04-01T12:00:00Z").getTime(), + }, + }); + }); + + it("requestBody with required: false", async () => { + const client = createClient({ baseUrl }); + + useMockRequestHandler({ + baseUrl, + method: "put", + path: "/blogposts-optional", + status: 201, + }); + + // assert missing `body` doesn’t raise a TS error + await client.PUT("/blogposts-optional"); + + // assert error on type mismatch + // @ts-expect-error + await client.PUT("/blogposts-optional", { body: { error: true } }); + + // (no error) + await client.PUT("/blogposts-optional", { + body: { + title: "", + publish_date: 3, + body: "", + }, + }); + }); + }); + }); +}); diff --git a/packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json b/packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json new file mode 100644 index 000000000..bd513a8ea --- /dev/null +++ b/packages/openapi-fetch/test/no-strict-null-checks/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "strictNullChecks": false + }, + "include": ["."], + "exclude": [] +} diff --git a/packages/openapi-fetch/tsconfig.json b/packages/openapi-fetch/tsconfig.json index 384036bb9..c944b4a37 100644 --- a/packages/openapi-fetch/tsconfig.json +++ b/packages/openapi-fetch/tsconfig.json @@ -15,5 +15,5 @@ "types": ["vitest/globals"] }, "include": ["src", "test"], - "exclude": ["examples", "node_modules"] + "exclude": ["examples", "node_modules", "test/no-strict-null-checks"] }