diff --git a/src/core/query-string.t-test.ts b/src/core/query-string.t-test.ts new file mode 100644 index 0000000..5aa1c8c --- /dev/null +++ b/src/core/query-string.t-test.ts @@ -0,0 +1,70 @@ +import { Equal, Expect } from "./type-test"; +import { + HasExcessiveQuery, + HasMissingQuery, + IsValidQuery, + NonOptionalKeys, + ToQueryUnion, +} from "./query-string"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ToQueryUnionCase = [ + Expect, "a">>, + Expect, "a" | "b">>, + Expect, "a" | "b">>, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type HasMissingQueryCase = [ + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, true>>, + Expect, true>>, + Expect, true>>, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type HasExcessiveQueryCase = [ + Expect, false>>, + Expect, true>>, + Expect, true>>, + Expect, false>>, + Expect, false>>, + Expect< + Equal, true> + >, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type NonOptionalKeysCase = [ + Expect, "a">>, + Expect, never>>, + Expect, "a">>, + Expect, "a" | "b">>, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type IsValidQueryCase = [ + Expect, true>>, + Expect, "E: maybe missing query: a">>, + Expect< + Equal< + IsValidQuery<{ a: string }, "a" | "b">, + "E: maybe excessive query: a" | "E: maybe excessive query: b" + > + >, + Expect, true>>, + Expect, true>>, + Expect< + Equal< + IsValidQuery<{ a: string; b: string }, "a">, + "E: maybe missing query: a" | "E: maybe missing query: b" + > + >, + Expect, true>>, +]; diff --git a/src/core/query-string.ts b/src/core/query-string.ts index ad73b30..ea3ec95 100644 --- a/src/core/query-string.ts +++ b/src/core/query-string.ts @@ -38,3 +38,40 @@ export type SetProperty = { ? T[P] : never; }; + +export type ExtractQuery = + URL extends `${string}?${infer Query}` ? Query : undefined; + +export type ToQueryUnion = + Query extends `${infer Key}=${string}&${infer Rest}` + ? Key | ToQueryUnion + : Query extends `${infer Key}=${string}` + ? Key + : `invalid query: ${Query}`; + +export type HasMissingQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record, + QueryKeys extends string, +> = NonOptionalKeys extends QueryKeys ? false : true; + +export type HasExcessiveQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record, + QueryKeys extends string, + // no union distribution +> = [QueryKeys] extends [keyof QueryDef] ? false : true; + +export type NonOptionalKeys = { + [K in keyof T]-?: undefined extends T[K] ? never : K; +}[keyof T]; + +export type IsValidQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record, + QueryKeys extends string, +> = [HasMissingQuery] extends [true] + ? `E: maybe missing query: ${keyof QueryDef & string}` + : [HasExcessiveQuery] extends [true] + ? `E: maybe excessive query: ${QueryKeys}` + : true; diff --git a/src/fetch/index.t-test.ts b/src/fetch/index.t-test.ts index 9eefde4..f2f99ab 100644 --- a/src/fetch/index.t-test.ts +++ b/src/fetch/index.t-test.ts @@ -220,6 +220,23 @@ const JSONT = JSON as JSONT; headers: { Cookie: "a=b" }, }); } + { + // @ts-expect-error 定義されているパラメータを指定していない場合はエラー + f(`/api/projects/projectA/workflow/packages/list?a=b`, { + headers: { Cookie: "a=b" }, + }); + } + { + // @ts-expect-error 定義されていないパラメータを指定した場合は型エラー + f(`/api/projects/projectA/workflow/packages/list?state=true&a=b`, { + headers: { Cookie: "a=b" }, + }); + + // @ts-expect-error 順序は関係ない + f(`/api/projects/projectA/workflow/packages/list?a=b&state=true`, { + headers: { Cookie: "a=b" }, + }); + } })(); } diff --git a/src/fetch/index.ts b/src/fetch/index.ts index e02927a..98ed876 100644 --- a/src/fetch/index.ts +++ b/src/fetch/index.ts @@ -13,21 +13,34 @@ import { StatusCode, IsAllOptional, CaseInsensitive, + ExtractQuery, + IsValidQuery, + ToQueryUnion, } from "../core"; import { UrlPrefixPattern, ToUrlParamPattern } from "../core"; import { TypedString } from "../json"; +type IsValidUrl< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record | undefined, + Url extends string, + Query extends string | undefined = ExtractQuery, + QueryKeys extends string = Query extends string ? ToQueryUnion : never, +> = IsValidQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types + QueryDef extends Record ? QueryDef : {}, + QueryKeys +>; + export type RequestInitT< InputMethod extends CaseInsensitiveMethod, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Body extends Record | string | undefined, + Body extends Record | string | undefined, HeadersObj extends string | Record | undefined, > = Omit & (InputMethod extends "get" | "GET" ? { method?: InputMethod } : { method: InputMethod }) & - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (Body extends Record + (Body extends Record ? IsAllOptional extends true ? { body?: Body | TypedString } : { body: TypedString } @@ -86,7 +99,7 @@ type FetchT = < ? MergeApiResponseBodies> : Record, >( - input: Input, + input: IsValidUrl extends true ? Input : never, init: RequestInitT< InputMethod, ApiP,