Skip to content

Commit a16ccd4

Browse files
authored
Add JsonStringifyResult type (#160)
1 parent 23fe83d commit a16ccd4

File tree

6 files changed

+152
-7
lines changed

6 files changed

+152
-7
lines changed

pkgs/typed-api-spec/.eslintrc.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const dRef = ["src/index.ts", "misc/**/*", "**/*.test.ts"];
1+
const dRef = ["src/index.ts", "misc/**/*", "**/*.test.ts", "**/*.t-test.ts"];
22
const depRules = [
33
{
44
module: "src/express",
@@ -17,7 +17,7 @@ const depRules = [
1717
},
1818
{
1919
module: "src/json",
20-
allowReferenceFrom: [...dRef, "src/fetch"],
20+
allowReferenceFrom: [...dRef, "src/fetch", "src/core"],
2121
allowSameModule: false,
2222
},
2323
{

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ClientResponse, StatusCode } from "./hono-types";
33
import { C } from "../compile-error-utils";
44
import { JSONSchema7 } from "json-schema";
55
import { StandardSchemaV1 } from "@standard-schema/spec";
6+
import { JsonStringifyResult } from "../json";
67

78
/**
89
* { // ApiEndpoints
@@ -230,7 +231,7 @@ export type AnyResponse = DefineResponse<any, any>;
230231
export type JsonSchemaResponse = DefineResponse<JSONSchema7, JSONSchema7>;
231232
export type ApiClientResponses<AResponses extends AnyApiResponses> = {
232233
[SC in keyof AResponses & StatusCode]: ClientResponse<
233-
ApiResBody<AResponses, SC>,
234+
JsonStringifyResult<ApiResBody<AResponses, SC>>,
234235
SC,
235236
"json",
236237
ApiResHeaders<AResponses, SC>

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ToApiEndpoints,
77
} from "../core";
88
import FetchT, { ValidateUrl } from "./index";
9-
import JSONT from "../json";
9+
import JSONT, { JsonStringifyResult } from "../json";
1010
import { Equal, Expect } from "../core/type-test";
1111
import { C } from "../compile-error-utils";
1212
import { ApiEndpointsSchema } from "../../dist";
@@ -386,3 +386,24 @@ type ValidateUrlTestCase = [
386386
}
387387
})();
388388
}
389+
390+
{
391+
const ResBody = z.object({ userId: z.date() });
392+
type ResBody = z.infer<typeof ResBody>;
393+
const spec = {
394+
"/": {
395+
get: {
396+
responses: { 200: { body: ResBody } },
397+
},
398+
},
399+
} satisfies ApiEndpointsSchema;
400+
(async () => {
401+
const f = fetch as FetchT<"", ToApiEndpoints<typeof spec>>;
402+
{
403+
const res = await f("/", {});
404+
405+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
406+
const _body: JsonStringifyResult<ResBody> = await res.json();
407+
}
408+
})();
409+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
141141
: never,
142142
LM extends Lowercase<InputMethod>,
143143
Query extends ApiP<E, CandidatePaths, LM, "query">,
144-
ResBody extends ApiP<
144+
Response extends ApiP<
145145
E,
146146
CandidatePaths,
147147
LM,
@@ -165,7 +165,7 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
165165
ApiP<E, CandidatePaths, LM, "headers">,
166166
InputMethod
167167
>,
168-
) => Promise<ResBody>;
168+
) => Promise<Response>;
169169

170170
export default FetchT;
171171

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
import { JsonStringifyResult } from ".";
3+
import { Equal, Expect } from "../core/type-test";
4+
5+
const l: unique symbol = Symbol("l");
6+
describe("JsonStringifyResult", () => {
7+
it("should work", () => {
8+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
9+
type MyType = {
10+
a: string;
11+
b: number;
12+
c: boolean;
13+
d: null;
14+
e: undefined;
15+
f: () => void;
16+
g: symbol;
17+
h: bigint;
18+
i: Date;
19+
j: { nested: string; undef: undefined };
20+
k: (string | undefined | Date)[];
21+
[l]: string;
22+
m: { toJSON: () => { x: number; y: string | undefined } };
23+
};
24+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
25+
type T = Expect<
26+
Equal<
27+
JsonStringifyResult<MyType>,
28+
{
29+
a: string;
30+
b: number;
31+
c: boolean;
32+
d: null;
33+
i: string;
34+
j: { nested: string };
35+
k: (string | null)[];
36+
// FIXME: y should be optional
37+
m: { x: number; y: string | undefined };
38+
}
39+
>
40+
>;
41+
42+
const example: MyType = {
43+
a: "hello",
44+
b: 123,
45+
c: true,
46+
d: null,
47+
e: undefined,
48+
f: () => {
49+
console.log("func");
50+
},
51+
g: Symbol("g"),
52+
h: 123n,
53+
i: new Date(),
54+
j: { nested: "world", undef: undefined },
55+
k: ["a", undefined, new Date("2021-01-01")],
56+
[l]: "symbol keyed value",
57+
m: { toJSON: () => ({ x: 1, y: undefined }) },
58+
};
59+
60+
expect(JSON.parse(JSON.stringify({ ...example, h: undefined }))).toEqual({
61+
a: "hello",
62+
b: 123,
63+
c: true,
64+
d: null,
65+
i: example.i.toISOString(),
66+
j: { nested: "world" },
67+
k: ["a", null, "2021-01-01T00:00:00.000Z"],
68+
m: { x: 1 },
69+
});
70+
});
71+
});

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

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,62 @@ export type JSON$stringifyT = <T>(
55
data: T,
66
replacer?: undefined,
77
space?: number | string | undefined,
8-
) => TypedString<T>;
8+
) => TypedString<JsonStringifyResult<T>>;
99

1010
type JSONT = Omit<JSON, "stringify"> & {
1111
stringify: JSON$stringifyT;
1212
};
1313

1414
export default JSONT;
15+
16+
// JSONとして有効なプリミティブ型 + Date
17+
type JsonPrimitive = string | number | boolean | null | Date;
18+
19+
// undefined | function | symbol | bigint は JSON化できない (除外 or null or エラー)
20+
// eslint-disable-next-line @typescript-eslint/ban-types
21+
type InvalidJsonValue = undefined | Function | symbol | bigint;
22+
23+
// 配列要素の変換: 不適切な値は null に
24+
type JsonifyArrayElement<T> = T extends InvalidJsonValue ? null : Jsonify<T>;
25+
26+
// オブジェクトの変換
27+
type JsonifyObject<T> = {
28+
// keyof T から string 型のキーのみを抽出 (シンボルキーを除外)
29+
[K in keyof T as K extends string
30+
? // プロパティの値 T[K] を Jsonify した結果を ProcessedValue とする
31+
Jsonify<T[K]> extends infer ProcessedValue
32+
? // ProcessedValue が 不適切な型なら、このプロパティ自体を除外 (never)
33+
ProcessedValue extends InvalidJsonValue
34+
? never
35+
: // そうでなければキー K を採用
36+
K
37+
: never
38+
: never]: Jsonify<T[K]>; // ↑で採用されたキー K に対して、変換後の値 ProcessedValue を割り当て
39+
};
40+
41+
// メインの再帰型
42+
type Jsonify<T> =
43+
// 1. toJSONメソッドを持つか? -> あればその返り値を Jsonify
44+
T extends { toJSON(): infer R }
45+
? Jsonify<R>
46+
: // 2. Dateか? -> string
47+
T extends Date
48+
? string
49+
: // 3. その他のプリミティブか? -> そのまま
50+
T extends JsonPrimitive
51+
? T
52+
: // 4. 不適切な値か? -> そのまま (呼び出し元で処理)
53+
T extends InvalidJsonValue
54+
? T
55+
: // 5. 配列か? -> 各要素を JsonifyArrayElement で変換
56+
T extends Array<infer E>
57+
? Array<JsonifyArrayElement<E>>
58+
: // 6. オブジェクトか? -> JsonifyObject で変換
59+
T extends object
60+
? JsonifyObject<T>
61+
: // 7. それ以外 (通常は到達しない) -> never
62+
never;
63+
64+
// 最終的な型: トップレベルでの undefined/function/symbol/bigint は undefined になる
65+
export type JsonStringifyResult<T> =
66+
Jsonify<T> extends InvalidJsonValue ? undefined : Jsonify<T>;

0 commit comments

Comments
 (0)