Skip to content

Commit 01f9c88

Browse files
committed
feat: improve declaration of response types
This changes the way that `HttpResponse` is declared. The language server now offers actual helpful autocomplete suggestions, and type inferencing behaves correctly as far as I can tell.
1 parent 81431c8 commit 01f9c88

File tree

3 files changed

+68
-40
lines changed

3 files changed

+68
-40
lines changed

packages/io-ts-http/src/httpResponse.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import * as t from 'io-ts';
33
import { Status } from '@api-ts/response';
44

55
export type HttpResponse = {
6-
[K: string]: t.Mixed;
6+
[K in Status]?: t.Mixed;
77
};
88

9-
type KnownResponses<Response extends HttpResponse> = {
10-
[K in Status]: K extends keyof Response ? K : never;
11-
}[Status];
9+
export type KnownResponses<Response extends HttpResponse> = {
10+
[K in keyof Response]: K extends Status
11+
? undefined extends Response[K]
12+
? never
13+
: K
14+
: never;
15+
}[keyof Response];
1216

13-
export const HttpResponseCodes: { [K in Status]: number } = {
17+
export const HttpResponseCodes = {
1418
ok: 200,
1519
invalidRequest: 400,
1620
unauthenticated: 401,
@@ -19,7 +23,23 @@ export const HttpResponseCodes: { [K in Status]: number } = {
1923
rateLimitExceeded: 429,
2024
internalError: 500,
2125
serviceUnavailable: 503,
22-
};
26+
} as const;
27+
28+
export type HttpResponseCodes = typeof HttpResponseCodes;
29+
30+
// Create a type-level assertion that the HttpResponseCodes map contains every key
31+
// in the Status union of string literals, and no unexpected keys. Violations of
32+
// this assertion will cause compile-time errors.
33+
//
34+
// Thanks to https://stackoverflow.com/a/67027737
35+
type ShapeOf<T> = Record<keyof T, any>;
36+
type AssertKeysEqual<X extends ShapeOf<Y>, Y extends ShapeOf<X>> = never;
37+
type _AssertHttpStatusCodeIsDefinedForAllResponses = AssertKeysEqual<
38+
{ [K in Status]: number },
39+
HttpResponseCodes
40+
>;
2341

24-
export type KnownHttpStatusCodes<Response extends HttpResponse> =
25-
typeof HttpResponseCodes[KnownResponses<Response>];
42+
export type ResponseTypeForStatus<
43+
Response extends HttpResponse,
44+
S extends keyof Response,
45+
> = Response[S] extends t.Mixed ? t.TypeOf<Response[S]> : never;

packages/io-ts-http/src/httpRoute.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as t from 'io-ts';
22

3-
import { HttpResponse } from './httpResponse';
4-
import { HttpRequestCodec } from './httpRequest';
3+
import { HttpResponse, KnownResponses } from './httpResponse';
4+
import { httpRequest, HttpRequestCodec } from './httpRequest';
55
import { Status } from '@api-ts/response';
66

77
export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
@@ -13,15 +13,17 @@ export type HttpRoute = {
1313
readonly response: HttpResponse;
1414
};
1515

16+
type ResponseItem<Status, Codec extends t.Mixed | undefined> = Codec extends t.Mixed
17+
? {
18+
type: Status;
19+
payload: t.TypeOf<Codec>;
20+
}
21+
: never;
22+
1623
export type RequestType<T extends HttpRoute> = t.TypeOf<T['request']>;
1724
export type ResponseType<T extends HttpRoute> = {
18-
[K in Status]: K extends keyof T['response']
19-
? {
20-
type: K;
21-
payload: t.TypeOf<T['response'][K]>;
22-
}
23-
: never;
24-
}[Status];
25+
[K in KnownResponses<T['response']>]: ResponseItem<K, T['response'][K]>;
26+
}[KnownResponses<T['response']>];
2527

2628
export type ApiSpec = {
2729
[Key: string]: {

packages/superagent-wrapper/src/request.ts

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import type { Response, SuperAgent, SuperAgentRequest } from 'superagent';
55
import type { SuperTest } from 'supertest';
66
import { URL } from 'url';
77
import { pipe } from 'fp-ts/function';
8+
import { Status } from '@api-ts/response';
89

910
type SuccessfulResponses<Route extends h.HttpRoute> = {
10-
[Status in h.KnownHttpStatusCodes<Route['response']>]: {
11-
status: Status;
11+
[R in h.KnownResponses<Route['response']>]: {
12+
status: h.HttpResponseCodes[R];
1213
error?: undefined;
13-
body: t.TypeOf<Route['response'][Status]>;
14+
body: h.ResponseTypeForStatus<Route['response'], R>;
1415
original: Response;
1516
};
16-
}[h.KnownHttpStatusCodes<Route['response']>];
17+
}[h.KnownResponses<Route['response']>];
1718

1819
type DecodedResponse<Route extends h.HttpRoute> =
1920
| SuccessfulResponses<Route>
@@ -28,17 +29,16 @@ const decodedResponse = <Route extends h.HttpRoute>(res: DecodedResponse<Route>)
2829

2930
type ExpectedDecodedResponse<
3031
Route extends h.HttpRoute,
31-
Status extends h.KnownHttpStatusCodes<Route['response']>,
32-
> = {
33-
body: t.TypeOf<Route['response'][Status]>;
34-
original: Response;
35-
};
32+
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
33+
> = DecodedResponse<Route> & { status: StatusCode };
3634

3735
type PatchedRequest<Req extends SuperAgentRequest, Route extends h.HttpRoute> = Req & {
3836
decode: () => Promise<DecodedResponse<Route>>;
39-
decodeExpecting: <Status extends h.KnownHttpStatusCodes<Route['response']>>(
40-
status: Status,
41-
) => Promise<ExpectedDecodedResponse<Route, Status>>;
37+
decodeExpecting: <
38+
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
39+
>(
40+
status: StatusCode,
41+
) => Promise<ExpectedDecodedResponse<Route, StatusCode>>;
4242
};
4343

4444
type SuperagentMethod = 'get' | 'post' | 'put' | 'delete';
@@ -84,6 +84,13 @@ export const supertestRequestFactory =
8484
return supertest[method](path);
8585
};
8686

87+
const hasCodecForStatus = <S extends Status>(
88+
responses: h.HttpResponse,
89+
status: S,
90+
): responses is { [K in S]: t.Mixed } => {
91+
return status in responses && responses[status] !== undefined;
92+
};
93+
8794
const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
8895
route: Route,
8996
req: Req,
@@ -94,11 +101,11 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
94101
req.then((res) => {
95102
const { body, status: statusCode } = res;
96103

97-
let status: string | undefined;
104+
let status: Status | undefined;
98105
// DISCUSS: Should we have this as a preprocessed const in io-ts-http?
99106
for (const [name, code] of Object.entries(h.HttpResponseCodes)) {
100107
if (statusCode === code) {
101-
status = name;
108+
status = name as Status;
102109
break;
103110
}
104111
}
@@ -111,7 +118,7 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
111118
});
112119
}
113120

114-
if (route.response[status] === undefined) {
121+
if (!hasCodecForStatus(route.response, status)) {
115122
return decodedResponse({
116123
// DISCUSS: what's this non-standard HTTP status code?
117124
status: 'decodeError',
@@ -124,10 +131,12 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
124131
route.response[status].decode(res.body),
125132
E.map((body) =>
126133
decodedResponse<Route>({
127-
status: statusCode,
134+
status: statusCode as h.HttpResponseCodes[h.KnownResponses<
135+
Route['response']
136+
>],
128137
body,
129138
original: res,
130-
}),
139+
} as SuccessfulResponses<Route>),
131140
),
132141
E.getOrElse((error) =>
133142
// DISCUSS: what's this non-standard HTTP status code?
@@ -142,19 +151,16 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
142151
});
143152

144153
patchedReq.decodeExpecting = <
145-
Status extends h.KnownHttpStatusCodes<Route['response']>,
154+
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
146155
>(
147-
status: Status,
156+
status: StatusCode,
148157
) =>
149158
patchedReq.decode().then((res) => {
150159
if (res.status !== status) {
151160
const error = res.error ?? `Unexpected status code ${res.status}`;
152161
throw new Error(JSON.stringify(error));
153162
} else {
154-
return {
155-
body: res.body,
156-
original: res.original,
157-
};
163+
return res as ExpectedDecodedResponse<Route, StatusCode>;
158164
}
159165
});
160166
return patchedReq;

0 commit comments

Comments
 (0)