Skip to content

Commit cec023d

Browse files
authored
fix type checking when strictNullChecks is disabled (#1833)
* fix identification of required properties when `strictNullChecks` is disabled # Conflicts: # packages/openapi-fetch/src/index.d.ts * test some scenarios with `strictNullChecks` disabled
1 parent f66be91 commit cec023d

File tree

8 files changed

+178
-14
lines changed

8 files changed

+178
-14
lines changed

Diff for: .changeset/lazy-dancers-push.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"openapi-typescript-helpers": patch
3+
"openapi-react-query": patch
4+
"openapi-fetch": patch
5+
---
6+
7+
Fix identification of required properties when `strictNullChecks` is disabled

Diff for: packages/openapi-fetch/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"test": "pnpm run \"/^test:/\"",
6060
"test:js": "vitest run",
6161
"test:ts": "tsc --noEmit",
62+
"test:ts-no-strict": "tsc --noEmit -p test/no-strict-null-checks/tsconfig.json",
6263
"test-e2e": "playwright test",
6364
"e2e-vite-build": "vite build test/fixtures/e2e",
6465
"e2e-vite-start": "vite preview test/fixtures/e2e",

Diff for: packages/openapi-fetch/src/index.d.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type {
22
ErrorResponse,
33
FilterKeys,
4-
HasRequiredKeys,
54
HttpMethod,
5+
IsOperationRequestBodyOptional,
66
MediaType,
77
OperationRequestBodyContent,
88
PathsWithMethod,
99
ResponseObjectMap,
10+
RequiredKeysOf,
1011
SuccessResponse,
1112
} from "openapi-typescript-helpers";
1213

@@ -82,14 +83,14 @@ export interface DefaultParamsOption {
8283
export type ParamsOption<T> = T extends {
8384
parameters: any;
8485
}
85-
? HasRequiredKeys<T["parameters"]> extends never
86+
? RequiredKeysOf<T["parameters"]> extends never
8687
? { params?: T["parameters"] }
8788
: { params: T["parameters"] }
8889
: DefaultParamsOption;
8990

9091
export type RequestBodyOption<T> = OperationRequestBodyContent<T> extends never
9192
? { body?: never }
92-
: undefined extends OperationRequestBodyContent<T>
93+
: IsOperationRequestBodyOptional<T> extends true
9394
? { body?: OperationRequestBodyContent<T> }
9495
: { body: OperationRequestBodyContent<T> };
9596

@@ -150,7 +151,7 @@ export interface Middleware {
150151
}
151152

152153
/** This type helper makes the 2nd function param required if params/requestBody are required; otherwise, optional */
153-
export type MaybeOptionalInit<Params extends Record<HttpMethod, {}>, Location extends keyof Params> = HasRequiredKeys<
154+
export type MaybeOptionalInit<Params extends Record<HttpMethod, {}>, Location extends keyof Params> = RequiredKeysOf<
154155
FetchOptions<FilterKeys<Params, Location>>
155156
> extends never
156157
? FetchOptions<FilterKeys<Params, Location>> | undefined
@@ -160,7 +161,7 @@ export type MaybeOptionalInit<Params extends Record<HttpMethod, {}>, Location ex
160161
// - Determines if the param is optional or not.
161162
// - Performs arbitrary [key: string] addition.
162163
// Note: the addition It MUST happen after all the inference happens (otherwise TS can’t infer if init is required or not).
163-
type InitParam<Init> = HasRequiredKeys<Init> extends never
164+
type InitParam<Init> = RequiredKeysOf<Init> extends never
164165
? [(Init & { [key: string]: unknown })?]
165166
: [Init & { [key: string]: unknown }];
166167

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { afterAll, beforeAll, describe, it } from "vitest";
2+
import createClient from "../../src/index.js";
3+
import { server, baseUrl, useMockRequestHandler } from "../fixtures/mock-server.js";
4+
import type { paths } from "../fixtures/api.js";
5+
6+
beforeAll(() => {
7+
server.listen({
8+
onUnhandledRequest: (request) => {
9+
throw new Error(`No request handler found for ${request.method} ${request.url}`);
10+
},
11+
});
12+
});
13+
14+
afterEach(() => server.resetHandlers());
15+
16+
afterAll(() => server.close());
17+
18+
describe("client", () => {
19+
describe("TypeScript checks", () => {
20+
describe("params", () => {
21+
it("is optional if no parameters are defined", async () => {
22+
const client = createClient<paths>({
23+
baseUrl,
24+
});
25+
26+
useMockRequestHandler({
27+
baseUrl,
28+
method: "get",
29+
path: "/self",
30+
status: 200,
31+
body: { message: "OK" },
32+
});
33+
34+
// assert no type error
35+
await client.GET("/self");
36+
37+
// assert no type error with empty params
38+
await client.GET("/self", { params: {} });
39+
});
40+
41+
it("checks types of optional params", async () => {
42+
const client = createClient<paths>({
43+
baseUrl,
44+
});
45+
46+
useMockRequestHandler({
47+
baseUrl,
48+
method: "get",
49+
path: "/self",
50+
status: 200,
51+
body: { message: "OK" },
52+
});
53+
54+
// assert no type error with no params
55+
await client.GET("/blogposts");
56+
57+
// assert no type error with empty params
58+
await client.GET("/blogposts", { params: {} });
59+
60+
// expect error on incorrect param type
61+
// @ts-expect-error
62+
await client.GET("/blogposts", { params: { query: { published: "yes" } } });
63+
64+
// expect error on extra params
65+
// @ts-expect-error
66+
await client.GET("/blogposts", { params: { query: { fake: true } } });
67+
});
68+
});
69+
70+
describe("body", () => {
71+
it("requires necessary requestBodies", async () => {
72+
const client = createClient<paths>({ baseUrl });
73+
74+
useMockRequestHandler({
75+
baseUrl,
76+
method: "put",
77+
path: "/blogposts",
78+
});
79+
80+
// expect error on missing `body`
81+
// @ts-expect-error
82+
await client.PUT("/blogposts");
83+
84+
// expect error on missing fields
85+
// @ts-expect-error
86+
await client.PUT("/blogposts", { body: { title: "Foo" } });
87+
88+
// (no error)
89+
await client.PUT("/blogposts", {
90+
body: {
91+
title: "Foo",
92+
body: "Bar",
93+
publish_date: new Date("2023-04-01T12:00:00Z").getTime(),
94+
},
95+
});
96+
});
97+
98+
it("requestBody with required: false", async () => {
99+
const client = createClient<paths>({ baseUrl });
100+
101+
useMockRequestHandler({
102+
baseUrl,
103+
method: "put",
104+
path: "/blogposts-optional",
105+
status: 201,
106+
});
107+
108+
// assert missing `body` doesn’t raise a TS error
109+
await client.PUT("/blogposts-optional");
110+
111+
// assert error on type mismatch
112+
// @ts-expect-error
113+
await client.PUT("/blogposts-optional", { body: { error: true } });
114+
115+
// (no error)
116+
await client.PUT("/blogposts-optional", {
117+
body: {
118+
title: "",
119+
publish_date: 3,
120+
body: "",
121+
},
122+
});
123+
});
124+
});
125+
});
126+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"strictNullChecks": false
5+
},
6+
"include": ["."],
7+
"exclude": []
8+
}

Diff for: packages/openapi-fetch/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@
1515
"types": ["vitest/globals"]
1616
},
1717
"include": ["src", "test"],
18-
"exclude": ["examples", "node_modules"]
18+
"exclude": ["examples", "node_modules", "test/no-strict-null-checks"]
1919
}

Diff for: packages/openapi-react-query/src/index.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
useSuspenseQuery,
1212
} from "@tanstack/react-query";
1313
import type { ClientMethod, FetchResponse, MaybeOptionalInit, Client as FetchClient } from "openapi-fetch";
14-
import type { HasRequiredKeys, HttpMethod, MediaType, PathsWithMethod } from "openapi-typescript-helpers";
14+
import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";
1515

1616
export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
1717
Method extends HttpMethod,
@@ -22,7 +22,7 @@ export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>,
2222
>(
2323
method: Method,
2424
url: Path,
25-
...[init, options, queryClient]: HasRequiredKeys<Init> extends never
25+
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
2626
? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
2727
: [Init & { [key: string]: unknown }, Options?, QueryClient?]
2828
) => UseQueryResult<Response["data"], Response["error"]>;
@@ -36,7 +36,7 @@ export type UseSuspenseQueryMethod<Paths extends Record<string, Record<HttpMetho
3636
>(
3737
method: Method,
3838
url: Path,
39-
...[init, options, queryClient]: HasRequiredKeys<Init> extends never
39+
...[init, options, queryClient]: RequiredKeysOf<Init> extends never
4040
? [(Init & { [key: string]: unknown })?, Options?, QueryClient?]
4141
: [Init & { [key: string]: unknown }, Options?, QueryClient?]
4242
) => UseSuspenseQueryResult<Response["data"], Response["error"]>;

Diff for: packages/openapi-typescript-helpers/index.d.ts

+26-5
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,17 @@ export type ResponseObjectMap<T> = T extends { responses: any } ? T["responses"]
9797
/** Return `content` for a Response Object */
9898
export type ResponseContent<T> = T extends { content: any } ? T["content"] : unknown;
9999

100-
/** Return `requestBody` for an Operation Object */
101-
export type OperationRequestBody<T> = T extends { requestBody?: any } ? T["requestBody"] : never;
100+
/** Return type of `requestBody` for an Operation Object */
101+
export type OperationRequestBody<T> = "requestBody" extends keyof T ? T["requestBody"] : never;
102+
103+
/** Internal helper to get object type with only the `requestBody` property */
104+
type PickRequestBody<T> = "requestBody" extends keyof T ? Pick<T, "requestBody"> : never;
105+
106+
/** Resolve to `true` if request body is optional, else `false` */
107+
export type IsOperationRequestBodyOptional<T> = RequiredKeysOf<PickRequestBody<T>> extends never ? true : false;
102108

103109
/** Internal helper used in OperationRequestBodyContent */
104-
export type OperationRequestBodyMediaContent<T> = undefined extends OperationRequestBody<T>
110+
export type OperationRequestBodyMediaContent<T> = IsOperationRequestBodyOptional<T> extends true
105111
? ResponseContent<NonNullable<OperationRequestBody<T>>> | undefined
106112
: ResponseContent<OperationRequestBody<T>>;
107113

@@ -152,7 +158,22 @@ export type GetValueWithDefault<Obj, KeyPattern, Default> = Obj extends any
152158
export type MediaType = `${string}/${string}`;
153159
/** Return any media type containing "json" (works for "application/json", "application/vnd.api+json", "application/vnd.oai.openapi+json") */
154160
export type JSONLike<T> = FilterKeys<T, `${string}/json`>;
155-
/** Filter objects that have required keys */
161+
162+
/**
163+
* Filter objects that have required keys
164+
* @deprecated Use `RequiredKeysOf` instead
165+
*/
156166
export type FindRequiredKeys<T, K extends keyof T> = K extends unknown ? (undefined extends T[K] ? never : K) : K;
157-
/** Does this object contain required keys? */
167+
/**
168+
* Does this object contain required keys?
169+
* @deprecated Use `RequiredKeysOf` instead
170+
*/
158171
export type HasRequiredKeys<T> = FindRequiredKeys<T, keyof T>;
172+
173+
/** Helper to get the required keys of an object. If no keys are required, will be `undefined` with strictNullChecks enabled, else `never` */
174+
type RequiredKeysOfHelper<T> = {
175+
// biome-ignore lint/complexity/noBannedTypes: `{}` is necessary here
176+
[K in keyof T]: {} extends Pick<T, K> ? never : K;
177+
}[keyof T];
178+
/** Get the required keys of an object, or `never` if no keys are required */
179+
export type RequiredKeysOf<T> = RequiredKeysOfHelper<T> extends undefined ? never : RequiredKeysOfHelper<T>;

0 commit comments

Comments
 (0)