Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pkgs/typed-api-spec/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const dRef = ["src/index.ts", "misc/**/*", "**/*.test.ts"];
const dRef = ["src/index.ts", "misc/**/*", "**/*.test.ts", "**/*.t-test.ts"];
const depRules = [
{
module: "src/express",
Expand All @@ -17,7 +17,7 @@ const depRules = [
},
{
module: "src/json",
allowReferenceFrom: [...dRef, "src/fetch"],
allowReferenceFrom: [...dRef, "src/fetch", "src/core"],
allowSameModule: false,
},
{
Expand Down
3 changes: 2 additions & 1 deletion pkgs/typed-api-spec/src/core/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ClientResponse, StatusCode } from "./hono-types";
import { C } from "../compile-error-utils";
import { JSONSchema7 } from "json-schema";
import { StandardSchemaV1 } from "@standard-schema/spec";
import { JsonStringifyResult } from "../json";

/**
* { // ApiEndpoints
Expand Down Expand Up @@ -230,7 +231,7 @@ export type AnyResponse = DefineResponse<any, any>;
export type JsonSchemaResponse = DefineResponse<JSONSchema7, JSONSchema7>;
export type ApiClientResponses<AResponses extends AnyApiResponses> = {
[SC in keyof AResponses & StatusCode]: ClientResponse<
ApiResBody<AResponses, SC>,
JsonStringifyResult<ApiResBody<AResponses, SC>>,
SC,
"json",
ApiResHeaders<AResponses, SC>
Expand Down
23 changes: 22 additions & 1 deletion pkgs/typed-api-spec/src/fetch/index.t-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
ToApiEndpoints,
} from "../core";
import FetchT, { ValidateUrl } from "./index";
import JSONT from "../json";
import JSONT, { JsonStringifyResult } from "../json";
import { Equal, Expect } from "../core/type-test";
import { C } from "../compile-error-utils";
import { ApiEndpointsSchema } from "../../dist";
Expand Down Expand Up @@ -386,3 +386,24 @@ type ValidateUrlTestCase = [
}
})();
}

{
const ResBody = z.object({ userId: z.date() });
type ResBody = z.infer<typeof ResBody>;
const spec = {
"/": {
get: {
responses: { 200: { body: ResBody } },
},
},
} satisfies ApiEndpointsSchema;
(async () => {
const f = fetch as FetchT<"", ToApiEndpoints<typeof spec>>;
{
const res = await f("/", {});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _body: JsonStringifyResult<ResBody> = await res.json();
}
})();
}
4 changes: 2 additions & 2 deletions pkgs/typed-api-spec/src/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
: never,
LM extends Lowercase<InputMethod>,
Query extends ApiP<E, CandidatePaths, LM, "query">,
ResBody extends ApiP<
Response extends ApiP<
E,
CandidatePaths,
LM,
Expand All @@ -165,7 +165,7 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
ApiP<E, CandidatePaths, LM, "headers">,
InputMethod
>,
) => Promise<ResBody>;
) => Promise<Response>;

export default FetchT;

Expand Down
71 changes: 71 additions & 0 deletions pkgs/typed-api-spec/src/json/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { JsonStringifyResult } from ".";
import { Equal, Expect } from "../core/type-test";

const l: unique symbol = Symbol("l");
describe("JsonStringifyResult", () => {
it("should work", () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type MyType = {
a: string;
b: number;
c: boolean;
d: null;
e: undefined;
f: () => void;
g: symbol;
h: bigint;
i: Date;
j: { nested: string; undef: undefined };
k: (string | undefined | Date)[];
[l]: string;
m: { toJSON: () => { x: number; y: string | undefined } };
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type T = Expect<
Equal<
JsonStringifyResult<MyType>,
{
a: string;
b: number;
c: boolean;
d: null;
i: string;
j: { nested: string };
k: (string | null)[];
// FIXME: y should be optional
m: { x: number; y: string | undefined };
}
>
>;

const example: MyType = {
a: "hello",
b: 123,
c: true,
d: null,
e: undefined,
f: () => {
console.log("func");
},
g: Symbol("g"),
h: 123n,
i: new Date(),
j: { nested: "world", undef: undefined },
k: ["a", undefined, new Date("2021-01-01")],
[l]: "symbol keyed value",
m: { toJSON: () => ({ x: 1, y: undefined }) },
};

expect(JSON.parse(JSON.stringify({ ...example, h: undefined }))).toEqual({
a: "hello",
b: 123,
c: true,
d: null,
i: example.i.toISOString(),
j: { nested: "world" },
k: ["a", null, "2021-01-01T00:00:00.000Z"],
m: { x: 1 },
});
});
});
54 changes: 53 additions & 1 deletion pkgs/typed-api-spec/src/json/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,62 @@ export type JSON$stringifyT = <T>(
data: T,
replacer?: undefined,
space?: number | string | undefined,
) => TypedString<T>;
) => TypedString<JsonStringifyResult<T>>;

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

export default JSONT;

// JSONとして有効なプリミティブ型 + Date
type JsonPrimitive = string | number | boolean | null | Date;

// undefined | function | symbol | bigint は JSON化できない (除外 or null or エラー)
// eslint-disable-next-line @typescript-eslint/ban-types
type InvalidJsonValue = undefined | Function | symbol | bigint;

// 配列要素の変換: 不適切な値は null に
type JsonifyArrayElement<T> = T extends InvalidJsonValue ? null : Jsonify<T>;

// オブジェクトの変換
type JsonifyObject<T> = {
// keyof T から string 型のキーのみを抽出 (シンボルキーを除外)
[K in keyof T as K extends string
? // プロパティの値 T[K] を Jsonify した結果を ProcessedValue とする
Jsonify<T[K]> extends infer ProcessedValue
? // ProcessedValue が 不適切な型なら、このプロパティ自体を除外 (never)
ProcessedValue extends InvalidJsonValue
? never
: // そうでなければキー K を採用
K
: never
: never]: Jsonify<T[K]>; // ↑で採用されたキー K に対して、変換後の値 ProcessedValue を割り当て
};

// メインの再帰型
type Jsonify<T> =
// 1. toJSONメソッドを持つか? -> あればその返り値を Jsonify
T extends { toJSON(): infer R }
? Jsonify<R>
: // 2. Dateか? -> string
T extends Date
? string
: // 3. その他のプリミティブか? -> そのまま
T extends JsonPrimitive
? T
: // 4. 不適切な値か? -> そのまま (呼び出し元で処理)
T extends InvalidJsonValue
? T
: // 5. 配列か? -> 各要素を JsonifyArrayElement で変換
T extends Array<infer E>
? Array<JsonifyArrayElement<E>>
: // 6. オブジェクトか? -> JsonifyObject で変換
T extends object
? JsonifyObject<T>
: // 7. それ以外 (通常は到達しない) -> never
never;

// 最終的な型: トップレベルでの undefined/function/symbol/bigint は undefined になる
export type JsonStringifyResult<T> =
Jsonify<T> extends InvalidJsonValue ? undefined : Jsonify<T>;