Skip to content

Commit 04f9779

Browse files
authored
Res header (#126)
* Add ImmutableHeaders * Test headers type inference * Add doc
1 parent 24490bb commit 04f9779

File tree

10 files changed

+129
-10
lines changed

10 files changed

+129
-10
lines changed

Diff for: docs/docs/04_client.md

+25-4
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@ const data = await res.json(); // data is { userNames: string[] }
4242
If the response have multiple status codes, response type is union of each status code type.
4343

4444
```typescript
45+
type Headers = { headers: { 'Content-Type': 'application/json' } };
4546
type Spec = DefineApiEndpoints<{
4647
"/users": {
4748
get: {
4849
responses: {
49-
200: { body: { names: string[] }; };
50-
201: { body: { ok: boolean }; };
51-
400: { body: { message: string; }; };
52-
500: { body: { internalError: string; }; };
50+
200: { body: { names: string[] }; } & Headers;
51+
201: { body: { ok: boolean }; } & Headers;
52+
400: { body: { message: string; }; } & Headers;
53+
500: { body: { internalError: string; }; } & Headers;
5354
};
5455
};
5556
}
@@ -61,6 +62,12 @@ if (!res.ok) {
6162
// If res.ok is false, status code is 400 or 500
6263
// So res.json() returns { message: string } | { internalError: string }
6364
const data = await res.json();
65+
66+
// Response headers are also type-checked. Content-Type is always 'application/json'
67+
const contentType: 'application/json' = res.headers.get('Content-Type');
68+
// and, hasContentType is inferred as true, not boolean
69+
const hasContentType: true = res.headers.has('Content-Type');
70+
6471
return console.error(data);
6572
}
6673
// If res.ok is true, status code is 200 or 201
@@ -69,6 +76,20 @@ const data = await res.json(); // names is string[]
6976
console.log(data);
7077
```
7178

79+
:::info[Response headers limitation]
80+
81+
Response headers are treated as an immutable object for strict type checking.
82+
It means that you can not `append`, `set` or `delete` operation after the response object is created.
83+
This is a limitation of the type system, not a runtime change. If you need mutable operations, you can cast types.
84+
85+
```typescript
86+
const immutableHeaders = res.headers
87+
const mutableHeaders = res.headers as Headers;
88+
```
89+
90+
:::
91+
92+
7293
### Path & Path parameters
7394

7495
zero-fetch accepts only the path that is defined in the API specification.

Diff for: examples/fastify/zod/fetch.ts

+4
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ const main = async () => {
5050
const r = await res.json();
5151
console.log(`${path}:${method} => ${r.userId}`);
5252
console.log(res.headers.get("Content-Type"));
53+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
54+
const contentType: "application/json" = res.headers.get("Content-Type");
55+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
56+
const hasContentType: true = res.headers.has("Content-Type");
5357
} else {
5458
// e is the response schema defined in pathMap["/users"]["post"].res other than "20X"
5559
const e = await res.text();

Diff for: examples/spec/zod.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { z } from "zod";
22
import { ToApiEndpoints, ZodApiEndpoints } from "../../src";
33

44
const JsonHeader = z.union([
5-
z.object({ "content-type": z.string() }),
6-
z.object({ "Content-Type": z.string() }),
5+
z.object({ "content-type": z.literal("application/json") }),
6+
z.object({ "Content-Type": z.literal("application/json") }),
77
]);
88
export const pathMap = {
99
"/users": {

Diff for: src/core/headers.t-test.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ImmutableHeaders } from "./headers";
2+
3+
{
4+
type ContentType =
5+
| { "Content-Type": "application/json" }
6+
| { "content-type": "application/json" };
7+
const headers = new Headers({
8+
"Content-Type": "application/json",
9+
}) as unknown as ImmutableHeaders<ContentType & { optionalKey?: string }>;
10+
11+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
12+
const contentType: "application/json" = headers.get("Content-Type");
13+
14+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
15+
const contentType2: "application/json" = headers.get("content-type");
16+
17+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
18+
const hasContentType: true = headers.has("Content-Type");
19+
20+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
21+
const optionalKey: boolean = headers.has("optionalKey");
22+
}

Diff for: src/core/headers.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { AllKeys, AllValues, IsOptional } from "./type";
2+
3+
export interface ImmutableHeaders<H extends Record<string, string>>
4+
extends Omit<Headers, "set" | "append" | "delete"> {
5+
get<Name extends AllKeys<H>>(name: Name): AllValues<H, Name>;
6+
has<Name extends AllKeys<H>>(
7+
name: Name,
8+
): IsOptional<H, Name> extends true ? boolean : true;
9+
forEach(
10+
callbackfn: <Name extends AllKeys<H> & string>(
11+
value: AllValues<H, Name>,
12+
key: Name,
13+
parent: Headers,
14+
) => void,
15+
): void;
16+
}

Diff for: src/core/hono-types.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ImmutableHeaders } from "./headers";
2+
13
// eslint-disable-next-line @typescript-eslint/no-explicit-any
24
type BlankRecordToNever<T> = T extends any
35
? T extends null
@@ -13,7 +15,8 @@ export interface ClientResponse<
1315
T,
1416
U extends number = StatusCode,
1517
F extends ResponseFormat = ResponseFormat,
16-
> extends globalThis.Response {
18+
H extends Record<string, string> = Record<string, string>,
19+
> extends Omit<globalThis.Response, "headers"> {
1720
readonly body: ReadableStream | null;
1821
readonly bodyUsed: boolean;
1922
ok: U extends SuccessStatusCode
@@ -23,7 +26,7 @@ export interface ClientResponse<
2326
: boolean;
2427
status: U;
2528
statusText: string;
26-
headers: Headers;
29+
headers: ImmutableHeaders<H>;
2730
url: string;
2831
redirect(url: string, status: number): Response;
2932
clone(): Response;

Diff for: src/core/spec.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ export type ApiRes<
119119
AResponses extends AnyApiResponses,
120120
SC extends keyof AResponses & StatusCode,
121121
> = AResponses[SC] extends AnyResponse ? AResponses[SC]["body"] : undefined;
122+
export type ApiResHeaders<
123+
AResponses extends AnyApiResponses,
124+
SC extends keyof AResponses & StatusCode,
125+
> = AResponses[SC] extends AnyResponse
126+
? AResponses[SC]["headers"]
127+
: Record<string, never>;
122128
export type AnyApiResponses = DefineApiResponses<AnyResponse>;
123129
export type DefineApiResponses<Response extends AnyResponse> = Partial<
124130
Record<StatusCode, Response>
@@ -130,7 +136,8 @@ export type ApiClientResponses<AResponses extends AnyApiResponses> = {
130136
[SC in keyof AResponses & StatusCode]: ClientResponse<
131137
ApiRes<AResponses, SC>,
132138
SC,
133-
"json"
139+
"json",
140+
ApiResHeaders<AResponses, SC>
134141
>;
135142
};
136143
export type MergeApiResponseBodies<AR extends AnyApiResponses> =

Diff for: src/core/type.t-test.ts

+22
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { Equal, Expect } from "./type-test";
22
import {
3+
AllKeys,
4+
AllValues,
35
CountChar,
46
ExtractByPrefix,
57
FilterNever,
68
IsAllOptional,
79
IsEqualNumber,
10+
IsOptional,
811
Replace,
912
ReplaceAll,
1013
SameSlashNum,
@@ -110,6 +113,13 @@ type SameSlashNumTestCases = [
110113
Expect<Equal<SameSlashNum<`/${string}`, "/a/b">, false>>,
111114
];
112115

116+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
117+
type IsOptionalTestCases = [
118+
// eslint-disable-next-line @typescript-eslint/ban-types
119+
Expect<Equal<IsOptional<{ a: string; b?: string }, "a">, false>>,
120+
Expect<Equal<IsOptional<{ a: string; b?: string }, "b">, true>>,
121+
];
122+
113123
// eslint-disable-next-line @typescript-eslint/no-unused-vars
114124
type IsAllOptionalTestCases = [
115125
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -119,3 +129,15 @@ type IsAllOptionalTestCases = [
119129
Expect<Equal<IsAllOptional<{ a?: string; b: string }>, false>>,
120130
Expect<Equal<IsAllOptional<{ a?: string; b?: string }>, true>>,
121131
];
132+
133+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
134+
type AllKeysTestCases = [
135+
Expect<Equal<AllKeys<{ a: string } | { b: string }>, "a" | "b">>,
136+
Expect<Equal<AllKeys<{ a: string } | { a: string; b: string }>, "a" | "b">>,
137+
];
138+
139+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
140+
type AllValuesTestCases = [
141+
Expect<Equal<AllValues<{ a: 1 } | { a: 2 }, "a">, 1 | 2>>,
142+
Expect<Equal<AllValues<{ a: 1; b: 3 } | { a: 2 }, "b">, 3>>,
143+
];

Diff for: src/core/type.ts

+14
Original file line numberDiff line numberDiff line change
@@ -143,5 +143,19 @@ export type SameSlashNum<P1 extends string, P2 extends string> = IsEqualNumber<
143143
CountChar<P2, "/">
144144
>;
145145

146+
export type IsOptional<T, K extends keyof T> =
147+
// eslint-disable-next-line @typescript-eslint/ban-types
148+
{} extends Pick<T, K> ? true : false;
149+
146150
// eslint-disable-next-line @typescript-eslint/ban-types
147151
export type IsAllOptional<T> = {} extends T ? true : false;
152+
153+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
154+
export type AllKeys<T> = T extends any ? keyof T : never;
155+
156+
export type AllValues<T, Key extends AllKeys<T>> = T extends {
157+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
158+
[key in Key]?: any;
159+
}
160+
? T[Key]
161+
: never;

Diff for: src/fetch/index.t-test.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ type ValidateUrlTestCase = [
3636
type Spec = DefineApiEndpoints<{
3737
"/users": {
3838
get: {
39-
responses: { 200: { body: { prop: string } } };
39+
responses: {
40+
200: {
41+
body: { prop: string };
42+
headers: { "Content-Type": "application/json" };
43+
};
44+
};
4045
};
4146
};
4247
}>;
@@ -49,6 +54,11 @@ type ValidateUrlTestCase = [
4954
// methodを省略した場合はgetとして扱う
5055
const res = await f("/users", {});
5156
(await res.json()).prop;
57+
58+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
59+
const contentType: "application/json" = res.headers.get("Content-Type");
60+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
61+
const hasContentType: true = res.headers.has("Content-Type");
5262
}
5363
})();
5464
}

0 commit comments

Comments
 (0)