Skip to content

Commit 45f971d

Browse files
authored
Better error (#118)
* Add NoPathError * Rename to C (Compile error message utility) * Return more explicit error from ValidateQuery * Simplify and more comment
1 parent ec98394 commit 45f971d

File tree

7 files changed

+132
-53
lines changed

7 files changed

+132
-53
lines changed

Diff for: src/compile-error-utils.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Type Utilities for Compile error message
2+
// eslint-disable-next-line @typescript-eslint/no-namespace
3+
export declare namespace C {
4+
const e: unique symbol;
5+
const ok: unique symbol;
6+
export type E<MSG> = { [e]: MSG };
7+
export type OK = { [ok]: true };
8+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9+
export type AnyE = E<any>;
10+
}

Diff for: src/core/query-string.t-test.ts

+14-11
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import { Equal, Expect } from "./type-test";
22
import {
33
HasExcessiveQuery,
44
HasMissingQuery,
5-
IsValidQuery,
5+
ValidateQuery,
66
NonOptionalKeys,
77
ToQueryUnion,
8+
ExcessiveQueryError,
9+
MissingQueryError,
810
} from "./query-string";
11+
import { C } from "../compile-error-utils";
912

1013
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1114
type ToQueryUnionCase = [
@@ -49,22 +52,22 @@ type NonOptionalKeysCase = [
4952
];
5053

5154
// eslint-disable-next-line @typescript-eslint/no-unused-vars
52-
type IsValidQueryCase = [
53-
Expect<Equal<IsValidQuery<{ a: string }, "a">, true>>,
54-
Expect<Equal<IsValidQuery<{ a: string }, "b">, "E: maybe missing query: a">>,
55+
type ValidateQueryCase = [
56+
Expect<Equal<ValidateQuery<{ a: string }, "a">, C.OK>>,
57+
Expect<Equal<ValidateQuery<{ a: string }, "b">, MissingQueryError<"a">>>,
5558
Expect<
5659
Equal<
57-
IsValidQuery<{ a: string }, "a" | "b">,
58-
"E: maybe excessive query: a" | "E: maybe excessive query: b"
60+
ValidateQuery<{ a: string }, "a" | "b">,
61+
ExcessiveQueryError<"a" | "b">
5962
>
6063
>,
61-
Expect<Equal<IsValidQuery<{ a: string; b?: string }, "a">, true>>,
62-
Expect<Equal<IsValidQuery<{ a: string; b?: string }, "a" | "b">, true>>,
64+
Expect<Equal<ValidateQuery<{ a: string; b?: string }, "a">, C.OK>>,
65+
Expect<Equal<ValidateQuery<{ a: string; b?: string }, "a" | "b">, C.OK>>,
6366
Expect<
6467
Equal<
65-
IsValidQuery<{ a: string; b: string }, "a">,
66-
"E: maybe missing query: a" | "E: maybe missing query: b"
68+
ValidateQuery<{ a: string; b: string }, "a">,
69+
MissingQueryError<"a" | "b">
6770
>
6871
>,
69-
Expect<Equal<IsValidQuery<{ a: string; b: string }, "a" | "b">, true>>,
72+
Expect<Equal<ValidateQuery<{ a: string; b: string }, "a" | "b">, C.OK>>,
7073
];

Diff for: src/core/query-string.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// https://github.com/type-challenges/type-challenges/issues/21419
2+
import { C } from "../compile-error-utils";
3+
24
export type ParseQueryString<S extends string> = S extends ""
35
? Record<string, never>
46
: MergeParams<SplitParams<S>>;
@@ -66,12 +68,20 @@ export type NonOptionalKeys<T> = {
6668
[K in keyof T]-?: undefined extends T[K] ? never : K;
6769
}[keyof T];
6870

69-
export type IsValidQuery<
71+
export type MissingQueryError<Keys extends string> = {
72+
reason: `missing query`;
73+
keys: Keys;
74+
};
75+
export type ExcessiveQueryError<Keys extends string> = {
76+
reason: `excessive query`;
77+
keys: Keys;
78+
};
79+
export type ValidateQuery<
7080
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7181
QueryDef extends Record<string, any>,
7282
QueryKeys extends string,
7383
> = [HasMissingQuery<QueryDef, QueryKeys>] extends [true]
74-
? `E: maybe missing query: ${keyof QueryDef & string}`
84+
? MissingQueryError<keyof QueryDef & string>
7585
: [HasExcessiveQuery<QueryDef, QueryKeys>] extends [true]
76-
? `E: maybe excessive query: ${QueryKeys}`
77-
: true;
86+
? ExcessiveQueryError<QueryKeys>
87+
: C.OK;

Diff for: src/core/spec.ts

+12-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ParseUrlParams } from "./url";
22
import { ClientResponse, StatusCode } from "./hono-types";
3+
import { C } from "../compile-error-utils";
34

45
/**
56
* { // ApiEndpoints
@@ -23,7 +24,7 @@ export const Method = [
2324
"head",
2425
] as const;
2526
export type Method = (typeof Method)[number];
26-
export type CaseInsensitive<S extends string> = Uppercase<S> | Lowercase<S>;
27+
export type CaseInsensitive<S extends string> = S | Uppercase<S> | Lowercase<S>;
2728
export type CaseInsensitiveMethod = Method | Uppercase<Method>;
2829
export const isMethod = (x: unknown): x is Method =>
2930
Method.includes(x as Method);
@@ -83,14 +84,18 @@ type AsJsonApiSpec<AS extends ApiSpec> = Omit<AS, "headers" | "resHeaders"> & {
8384

8485
export type ApiP<
8586
E extends ApiEndpoints,
86-
Path extends keyof E & string,
87+
Path extends (keyof E & string) | C.AnyE,
8788
M extends Method,
8889
P extends keyof ApiSpec,
89-
> = E[Path] extends ApiEndpoint
90-
? E[Path][M] extends ApiSpec<ParseUrlParams<Path>>
91-
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
92-
E[Path][M][P] extends Record<string, any> | string
93-
? E[Path][M][P]
90+
> = Path extends keyof E & string
91+
? E[Path] extends ApiEndpoint
92+
? M extends Method
93+
? E[Path][M] extends ApiSpec<ParseUrlParams<Path>>
94+
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
95+
E[Path][M][P] extends Record<string, any> | string
96+
? E[Path][M][P]
97+
: undefined
98+
: undefined
9499
: undefined
95100
: undefined
96101
: undefined;

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

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Equal, Expect } from "./type-test";
22
import {
33
MatchedPatterns,
4+
NoPathError,
45
ParseHostAndPort,
56
ParseOriginAndPath,
67
ParseURL,
@@ -55,6 +56,7 @@ type ToUrlPatternTestCases = [
5556

5657
// eslint-disable-next-line @typescript-eslint/no-unused-vars
5758
type MatchedPatternsTestCases = [
59+
Expect<Equal<MatchedPatterns<string, "">, NoPathError>>,
5860
Expect<Equal<MatchedPatterns<"", "">, "">>,
5961
Expect<Equal<MatchedPatterns<"/1", "/:userId">, "/:userId">>,
6062
Expect<

Diff for: src/core/url.ts

+14-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ParseQueryString } from "./query-string";
22
import { ExtractByPrefix, SameSlashNum, Split, UndefinedTo } from "./type";
3+
import { C } from "../compile-error-utils";
34

45
type ExtractParams<T extends string> = ExtractByPrefix<T, ":">;
56

@@ -63,6 +64,8 @@ export type ToUrlPattern<T extends string> = T extends `${infer O}?${infer R}`
6364
? `${ToUrlParamPattern<O>}?${ToUrlPattern<R>}`
6465
: ToUrlParamPattern<T>;
6566

67+
export type NoPathError = C.E<"no matched path found">;
68+
6669
/**
6770
* Extract matched URL pattern from URL
6871
* T: URL
@@ -74,13 +77,17 @@ export type ToUrlPattern<T extends string> = T extends `${infer O}?${infer R}`
7477
* // => "/users/:userId"
7578
* ```
7679
*/
77-
export type MatchedPatterns<T extends string, Patterns extends string> = keyof {
78-
[P in Patterns as T extends ToUrlPattern<P>
79-
? SameSlashNum<P, T> extends true
80-
? P
81-
: never
82-
: never]: true;
83-
};
80+
export type MatchedPatterns<
81+
T extends string,
82+
Patterns extends string,
83+
Matched = {
84+
[P in Patterns as T extends ToUrlPattern<P>
85+
? SameSlashNum<P, T> extends true
86+
? P
87+
: never
88+
: never]: true;
89+
},
90+
> = Matched extends Record<string, never> ? NoPathError : keyof Matched;
8491

8592
/**
8693
* Parse host and port

Diff for: src/fetch/index.ts

+66-24
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,41 @@ import {
55
CaseInsensitiveMethod,
66
MatchedPatterns,
77
MergeApiResponseBodies,
8-
Method,
98
NormalizePath,
109
ParseURL,
1110
PathToUrlParamPattern,
1211
Replace,
1312
StatusCode,
1413
IsAllOptional,
15-
CaseInsensitive,
1614
ExtractQuery,
17-
IsValidQuery,
15+
ValidateQuery,
1816
ToQueryUnion,
17+
Method,
18+
CaseInsensitive,
1919
} from "../core";
2020
import { UrlPrefixPattern, ToUrlParamPattern } from "../core";
2121
import { TypedString } from "../json";
22+
import { C } from "../compile-error-utils";
2223

23-
type IsValidUrl<
24+
type ValidateUrl<
2425
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2526
QueryDef extends Record<string, unknown> | undefined,
2627
Url extends string,
2728
Query extends string | undefined = ExtractQuery<Url>,
2829
QueryKeys extends string = Query extends string ? ToQueryUnion<Query> : never,
29-
> = IsValidQuery<
30+
> = ValidateQuery<
3031
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
3132
QueryDef extends Record<string, any> ? QueryDef : {},
3233
QueryKeys
3334
>;
3435

3536
export type RequestInitT<
36-
InputMethod extends CaseInsensitiveMethod,
37+
CanOmitMethod extends boolean,
3738
Body extends Record<string, unknown> | string | undefined,
3839
HeadersObj extends string | Record<string, string> | undefined,
40+
InputMethod extends CaseInsensitiveMethod,
3941
> = Omit<RequestInit, "method" | "body" | "headers"> &
40-
(InputMethod extends "get" | "GET"
42+
(CanOmitMethod extends true
4143
? { method?: InputMethod }
4244
: { method: InputMethod }) &
4345
(Body extends Record<string, unknown>
@@ -57,8 +59,37 @@ export type RequestInitT<
5759

5860
/**
5961
* FetchT is a type for window.fetch like function but more strict type information
62+
*
63+
* @template UrlPrefix - url prefix of `Input`
64+
* For example, if `UrlPrefix` is "https://example.com", then `Input` must be `https://example.com/${string}`
65+
*
66+
* @template E - ApiEndpoints
67+
* E is used to infer the type of the acceptable path, response body, and more
6068
*/
6169
type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
70+
/**
71+
* internal type for FetchT
72+
*
73+
* @template UrlPattern - Acceptable url pattern
74+
* for example, if endpoints is defined as below:
75+
* { "/users": ..., "/users/:userId": ... }
76+
* then UrlPattern will be "/users" | "/users/:userId"
77+
*
78+
* @template InputPath - Extracted path from `Input`
79+
*
80+
* @template CandidatePaths - Matched paths from `InputPath` and `keyof E`
81+
*
82+
* @template AcceptableMethods - Acceptable methods for the matched path
83+
*
84+
* @template InputMethod - Method of the request
85+
*
86+
* @template LM - Lowercase of `InputMethod`
87+
*
88+
* @template Query - Query object
89+
*
90+
* @template ResBody - Response body
91+
*
92+
*/
6293
UrlPattern extends ToUrlParamPattern<`${UrlPrefix}${keyof E & string}`>,
6394
Input extends Query extends undefined
6495
? UrlPattern
@@ -70,40 +101,51 @@ type FetchT<UrlPrefix extends UrlPrefixPattern, E extends ApiEndpoints> = <
70101
ParseURL<Replace<Input, ToUrlParamPattern<UrlPrefix>, "">>["path"]
71102
>
72103
>,
73-
CandidatePaths extends string = MatchedPatterns<InputPath, keyof E & string>,
74-
InputMethod extends CaseInsensitive<keyof E[CandidatePaths] & string> &
75-
CaseInsensitiveMethod = CaseInsensitive<keyof E[CandidatePaths] & string> &
76-
CaseInsensitiveMethod,
77-
M extends Method = CaseInsensitive<"get"> extends InputMethod
78-
? "get"
79-
: Lowercase<InputMethod>,
80-
Query extends ApiP<E, CandidatePaths, M, "query"> = ApiP<
104+
CandidatePaths extends MatchedPatterns<
105+
InputPath,
106+
keyof E & string
107+
> = MatchedPatterns<InputPath, keyof E & string>,
108+
AcceptableMethods extends CandidatePaths extends string
109+
? Extract<Method, keyof E[CandidatePaths]>
110+
: never = CandidatePaths extends string
111+
? Extract<Method, keyof E[CandidatePaths]>
112+
: never,
113+
InputMethod extends CaseInsensitive<AcceptableMethods> = Extract<
114+
AcceptableMethods,
115+
"get"
116+
>,
117+
LM extends Lowercase<InputMethod> = Lowercase<InputMethod>,
118+
Query extends ApiP<E, CandidatePaths, LM, "query"> = ApiP<
81119
E,
82120
CandidatePaths,
83-
M,
121+
LM,
84122
"query"
85123
>,
86124
ResBody extends ApiP<
87125
E,
88126
CandidatePaths,
89-
M,
127+
LM,
90128
"responses"
91129
> extends AnyApiResponses
92-
? MergeApiResponseBodies<ApiP<E, CandidatePaths, M, "responses">>
130+
? MergeApiResponseBodies<ApiP<E, CandidatePaths, LM, "responses">>
93131
: Record<StatusCode, never> = ApiP<
94132
E,
95133
CandidatePaths,
96-
M,
134+
LM,
97135
"responses"
98136
> extends AnyApiResponses
99-
? MergeApiResponseBodies<ApiP<E, CandidatePaths, M, "responses">>
137+
? MergeApiResponseBodies<ApiP<E, CandidatePaths, LM, "responses">>
100138
: Record<StatusCode, never>,
101139
>(
102-
input: IsValidUrl<Query, Input> extends true ? Input : never,
140+
input: ValidateUrl<Query, Input> extends C.OK
141+
? Input
142+
: ValidateUrl<Query, Input>,
103143
init: RequestInitT<
104-
InputMethod,
105-
ApiP<E, CandidatePaths, M, "body">,
106-
ApiP<E, CandidatePaths, M, "headers">
144+
// If `get` method is defined in the spec, method can be omitted
145+
"get" extends AcceptableMethods ? true : false,
146+
ApiP<E, CandidatePaths, LM, "body">,
147+
ApiP<E, CandidatePaths, LM, "headers">,
148+
InputMethod
107149
>,
108150
) => Promise<ResBody>;
109151

0 commit comments

Comments
 (0)