Skip to content

Commit a07ebe0

Browse files
authored
Fix init omitting logic (#163)
* More init doc * Add "And" utility type * request bodyが定義されている場合はinitは省略できない * Fix comment
1 parent 25c4f91 commit a07ebe0

File tree

5 files changed

+140
-22
lines changed

5 files changed

+140
-22
lines changed

pkgs/docs/docs/04_client/overview.md

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type Spec = DefineApiEndpoints<{
3535
}>;
3636

3737
const fetchT = fetch as FetchT<"", Spec>;
38-
const res = await fetchT("/users", {});
38+
const res = await fetchT("/users");
3939
const data = await res.json(); // data is { userNames: string[] }
4040
```
4141

@@ -57,7 +57,7 @@ type Spec = DefineApiEndpoints<{
5757
}>;
5858

5959
const fetchT = fetch as FetchT<"", Spec>;
60-
const res = await fetchT("/users", {});
60+
const res = await fetchT("/users");
6161
if (!res.ok) {
6262
// If res.ok is false, status code is 400 or 500
6363
// So res.json() returns { message: string } | { internalError: string }
@@ -105,10 +105,10 @@ type Spec = DefineApiEndpoints<{
105105
}>;
106106
const fetchT = fetch as FetchT<"", Spec>;
107107

108-
await fetchT("/users", {}); // OK
109-
await fetchT("/users/1", {}); // OK
110-
await fetchT("/posts", {}); // Error: Argument of type '"/posts"' is not assignable to parameter of type '"/users" | "/users/:id"'.
111-
await fetchT("/users/1/2", {}); // Error: Argument of type '"/users/1/2"' is not assignable to parameter of type '"/users" | "/users/:id"'.
108+
await fetchT("/users"); // OK
109+
await fetchT("/users/1"); // OK
110+
await fetchT("/posts"); // Error: Argument of type '"/posts"' is not assignable to parameter of type '"/users" | "/users/:id"'.
111+
await fetchT("/users/1/2"); // Error: Argument of type '"/users/1/2"' is not assignable to parameter of type '"/users" | "/users/:id"'.
112112
```
113113

114114
### Query
@@ -126,9 +126,9 @@ type Spec = DefineApiEndpoints<{
126126
}>;
127127

128128
const fetchT = fetch as FetchT<"", Spec>;
129-
await fetchT("/users?page=1", {}); // OK
130-
await fetchT("/users", {}); // Error: Argument of type string is not assignable to parameter of type MissingQueryError<"page">
131-
await fetchT("/users?page=1&noexist=1", {}); // Error: Argument of type string is not assignable to parameter of type ExcessiveQueryError<"noexist">
129+
await fetchT("/users?page=1"); // OK
130+
await fetchT("/users"); // Error: Argument of type string is not assignable to parameter of type MissingQueryError<"page">
131+
await fetchT("/users?page=1&noexist=1"); // Error: Argument of type string is not assignable to parameter of type ExcessiveQueryError<"noexist">
132132
```
133133

134134
### headers
@@ -175,6 +175,63 @@ await fetchT("/users", {
175175
await fetchT("/users", { method: "POST", body: JSONT.stringify({ name: 1 }) }); // Error: Type TypedString<{ userName: number; }> is not assignable to type TypedString<{ userName: string; }>
176176
```
177177

178+
### Init
179+
180+
zero-fetch enforces type safety for the `init` parameter of the fetch function. The `init` parameter can be omitted only if all of the following conditions are met:
181+
182+
- The endpoint defines an HTTP GET method.
183+
- All request headers defined for the endpoint are optional.
184+
185+
If any of these conditions are not satisfied, omitting the `init` parameter will result in a type error.
186+
187+
This behavior ensures that the fetch call adheres strictly to the API specification, preventing runtime errors due to missing or incorrect parameters.
188+
189+
```typescript
190+
type Spec = DefineApiEndpoints<{
191+
"/users": {
192+
get: {
193+
headers: { "x-api-key"?: string };
194+
responses: { 200: { body: { names: string[] } } };
195+
};
196+
};
197+
"/posts": {
198+
get: {
199+
headers: { "x-api-key": string };
200+
responses: { 200: { body: { posts: string[] } } };
201+
};
202+
};
203+
}>;
204+
205+
const fetchT = fetch as FetchT<"", Spec>;
206+
207+
await fetchT("/users"); // OK, because GET method is defined and headers are optional
208+
await fetchT("/users", { headers: { "x-api-key": "key" } }); // OK
209+
await fetchT("/users", { headers: {} }); // OK, because headers are optional
210+
await fetchT("/users", { method: "POST" }); // Error: POST method is not defined for this endpoint
211+
212+
await fetchT("/posts"); // Error: "x-api-key" header is required for this endpoint
213+
await fetchT("/posts", { headers: { "x-api-key": "key" } }); // OK
214+
```
215+
216+
```typescript
217+
type Spec = DefineApiEndpoints<{
218+
"/posts": {
219+
post: {
220+
body: { title: string };
221+
responses: { 201: { body: { id: string } } };
222+
};
223+
};
224+
}>;
225+
226+
const fetchT = fetch as FetchT<"", Spec>;
227+
228+
await fetchT("/posts"); // Error: GET method is not defined for this endpoint
229+
await fetchT("/posts", {
230+
method: "POST",
231+
body: JSON.stringify({ title: "New Post" }),
232+
}); // OK
233+
```
234+
178235
## API
179236

180237
### FetchT

pkgs/typed-api-spec/src/core/type.t-test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Equal, Expect } from "./type-test";
22
import {
33
AllKeys,
44
AllValues,
5+
And,
56
CountChar,
67
ExtractByPrefix,
78
FilterNever,
@@ -141,3 +142,17 @@ type AllValuesTestCases = [
141142
Expect<Equal<AllValues<{ a: 1 } | { a: 2 }, "a">, 1 | 2>>,
142143
Expect<Equal<AllValues<{ a: 1; b: 3 } | { a: 2 }, "b">, 3>>,
143144
];
145+
146+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
147+
type AndTestCases = [
148+
Expect<Equal<And<[]>, true>>, // An empty tuple evaluates to true
149+
Expect<Equal<And<[true]>, true>>, // Single element true
150+
Expect<Equal<And<[false]>, false>>, // Single element false
151+
Expect<Equal<And<[true, true]>, true>>, // All elements are true
152+
Expect<Equal<And<[true, false]>, false>>, // Contains false
153+
Expect<Equal<And<[false, true]>, false>>, // Contains false
154+
Expect<Equal<And<[false, false]>, false>>, // All elements are false
155+
Expect<Equal<And<[true, true, true]>, true>>, // Multiple true elements
156+
Expect<Equal<And<[true, true, false]>, false>>, // Multiple elements containing false
157+
Expect<Equal<And<boolean[]>, boolean>>, // boolean[] type
158+
];

pkgs/typed-api-spec/src/core/type.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,24 @@ export type AllValues<T, Key extends AllKeys<T>> = T extends {
159159
}
160160
? T[Key]
161161
: never;
162+
163+
/**
164+
* Computes the logical AND of a tuple of boolean types at the type level.
165+
* @template T - A readonly tuple of boolean types (true | false | boolean)
166+
*/
167+
export type And<T extends readonly boolean[]> = T extends readonly []
168+
? // The AND of an empty tuple is true (identity element of logical AND)
169+
true
170+
: // Can T be decomposed into [Head, ...Tail]? (Recursive step)
171+
T extends readonly [
172+
infer Head extends boolean,
173+
...infer Tail extends readonly boolean[],
174+
]
175+
? Head extends false
176+
? // If Head is false, the AND of the entire tuple is false
177+
false
178+
: // If Head is true, the result depends on the AND of the remaining elements (Tail)
179+
And<Tail>
180+
: // If T is neither [] nor [Head, ...Tail] (e.g., boolean[] type itself)
181+
// In this case, the result cannot be determined and is therefore boolean.
182+
boolean;

pkgs/typed-api-spec/src/fetch/index.t-test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,30 @@ type ValidateUrlTestCase = [
264264
})();
265265
}
266266

267+
{
268+
type Spec = DefineApiEndpoints<{
269+
"/users": {
270+
get: {
271+
// 本来、GETメソッドはbodyを持たないが、型エラーになることを確認するために定義
272+
body: { userName: string };
273+
responses: { 200: { body: { prop: string } } };
274+
};
275+
};
276+
}>;
277+
(async () => {
278+
const f = fetch as FetchT<"", Spec>;
279+
280+
// @ts-expect-error init cannot be omitted when request body is required
281+
await f("/users");
282+
283+
// Valid case: init is provided with required body
284+
await f("/users", {
285+
method: "get",
286+
body: JSONT.stringify({ userName: "test" }),
287+
});
288+
})();
289+
}
290+
267291
{
268292
type Spec = DefineApiEndpoints<{
269293
"/packages/list": {

pkgs/typed-api-spec/src/fetch/index.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ToQueryUnion,
1717
Method,
1818
CaseInsensitive,
19+
And,
1920
} from "../core";
2021
import { UrlPrefixPattern, ToUrlParamPattern } from "../core";
2122
import { TypedString } from "../json";
@@ -119,8 +120,12 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
119120
*
120121
* @template Headers - Request headers object of the endpoint
121122
*
123+
* @template CanOmitHeaders - Whether the headers property in the "init" parameter can be omitted
124+
*
122125
* @template Body - Request body object of the endpoint
123126
*
127+
* @template CanOmitBody - Whether the body property in the "init" parameter can be omitted
128+
*
124129
* @template Response - Response object of the endpoint that matches `CandidatePaths`
125130
*
126131
* @template ValidatedUrl - Result of URL validation
@@ -134,7 +139,7 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
134139
* If the endpoint defines a "get" method, then the method can be omitted
135140
*
136141
* @template CanOmitInit - Whether the "init" parameter can be omitted for the request
137-
* If the method can be omitted (`CanOmitMethod` is true) and the endpoint does not require headers, then the "init" parameter can be omitted
142+
* If the method can be omitted (`CanOmitMethod` is true), headers can be omitted (`CanOmitHeaders` is true), and body can be omitted (`CanOmitBody` is true), then the "init" parameter can be omitted
138143
*/
139144
UrlPattern extends ToUrlParamPattern<`${UrlPrefix}${keyof E & string}`>,
140145
Input extends Query extends undefined
@@ -154,7 +159,11 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
154159
LM extends Lowercase<InputMethod>,
155160
Query extends ApiP<E, CandidatePaths, LM, "query">,
156161
Headers extends ApiP<E, CandidatePaths, LM, "headers">,
162+
CanOmitHeaders extends Headers extends undefined
163+
? true
164+
: IsAllOptional<Headers>,
157165
Body extends ApiP<E, CandidatePaths, LM, "body">,
166+
CanOmitBody extends Body extends undefined ? true : IsAllOptional<Body>,
158167
Response extends ApiP<
159168
E,
160169
CandidatePaths,
@@ -164,22 +173,14 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
164173
? MergeApiResponseBodies<ApiP<E, CandidatePaths, LM, "responses">>
165174
: Record<StatusCode, never>,
166175
ValidatedUrl extends ValidateUrl<Query, Input>,
176+
CanOmitMethod extends "get" extends AcceptableMethods ? true : false,
167177
InputMethod extends CaseInsensitive<AcceptableMethods> = Extract<
168178
AcceptableMethods,
169179
"get"
170180
>,
171-
CanOmitMethod extends boolean = "get" extends AcceptableMethods
172-
? true
173-
: false,
174-
CanOmitInit extends boolean = CanOmitMethod extends true
175-
? Headers extends undefined
176-
? true
177-
: Headers extends Record<string, string>
178-
? IsAllOptional<Headers> extends true
179-
? true
180-
: false
181-
: false
182-
: false,
181+
CanOmitInit extends boolean = And<
182+
[CanOmitMethod, CanOmitHeaders, CanOmitBody]
183+
>,
183184
>(
184185
input: [ValidatedUrl] extends [C.OK | QueryParameterRequiredError]
185186
? Input

0 commit comments

Comments
 (0)