Skip to content

Commit d35ef92

Browse files
authoredMar 25, 2024··
fix: accept a narrower response body type by default (#2107)
1 parent 16fadfe commit d35ef92

File tree

7 files changed

+163
-83
lines changed

7 files changed

+163
-83
lines changed
 

‎src/core/HttpResponse.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DefaultBodyType, JsonBodyType } from './handlers/RequestHandler'
2+
import type { NoInfer } from './typeUtils'
23
import {
34
decorateResponse,
45
normalizeResponseInit,
@@ -48,7 +49,7 @@ export class HttpResponse extends Response {
4849
* HttpResponse.text('Error', { status: 500 })
4950
*/
5051
static text<BodyType extends string>(
51-
body?: BodyType | null,
52+
body?: NoInfer<BodyType> | null,
5253
init?: HttpResponseInit,
5354
): StrictResponse<BodyType> {
5455
const responseInit = normalizeResponseInit(init)
@@ -77,7 +78,7 @@ export class HttpResponse extends Response {
7778
* HttpResponse.json({ error: 'Not Authorized' }, { status: 401 })
7879
*/
7980
static json<BodyType extends JsonBodyType>(
80-
body?: BodyType | null,
81+
body?: NoInfer<BodyType> | null,
8182
init?: HttpResponseInit,
8283
): StrictResponse<BodyType> {
8384
const responseInit = normalizeResponseInit(init)

‎src/core/graphql.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ export type GraphQLRequestHandler = <
3131
| GraphQLHandlerNameSelector
3232
| DocumentNode
3333
| TypedDocumentNode<Query, Variables>,
34-
resolver: GraphQLResponseResolver<Query, Variables>,
34+
resolver: GraphQLResponseResolver<
35+
[Query] extends [never] ? GraphQLQuery : Query,
36+
Variables
37+
>,
3538
options?: RequestHandlerOptions,
3639
) => GraphQLHandler
3740

@@ -41,7 +44,7 @@ export type GraphQLResponseResolver<
4144
> = ResponseResolver<
4245
GraphQLResolverExtras<Variables>,
4346
null,
44-
GraphQLResponseBody<Query>
47+
GraphQLResponseBody<[Query] extends [never] ? GraphQLQuery : Query>
4548
>
4649

4750
function createScopedGraphQLHandler(
@@ -61,7 +64,7 @@ function createScopedGraphQLHandler(
6164

6265
function createGraphQLOperationHandler(url: Path) {
6366
return <
64-
Query extends Record<string, any>,
67+
Query extends GraphQLQuery = GraphQLQuery,
6568
Variables extends GraphQLVariables = GraphQLVariables,
6669
>(
6770
resolver: ResponseResolver<

‎src/core/handlers/GraphQLHandler.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,13 @@ export interface GraphQLJsonRequestBody<Variables extends GraphQLVariables> {
6868
variables?: Variables
6969
}
7070

71-
export interface GraphQLResponseBody<BodyType extends DefaultBodyType> {
72-
data?: BodyType | null
73-
errors?: readonly Partial<GraphQLError>[] | null
74-
}
71+
export type GraphQLResponseBody<BodyType extends DefaultBodyType> =
72+
| {
73+
data?: BodyType | null
74+
errors?: readonly Partial<GraphQLError>[] | null
75+
}
76+
| null
77+
| undefined
7578

7679
export function isDocumentNode(
7780
value: DocumentNode | any,

‎src/core/typeUtils.ts

+6
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ export type RequiredDeep<
1818
: RequiredDeep<NonNullable<Type[Key]>, U>
1919
}
2020
: Type
21+
22+
/**
23+
* @fixme Remove this once TS 5.4 is the lowest supported version.
24+
* Because "NoInfer" is a built-in type utility there.
25+
*/
26+
export type NoInfer<T> = [T][T extends any ? 0 : never]

‎test/typings/custom-resolver.test-d.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ it('custom http resolver has correct parameters type', () => {
3535

3636
http.get<{ id: string }, never, 'hello'>(
3737
'/user/:id',
38-
// @ts-expect-error Response body doesn't match the response type.
3938
withDelay(250, ({ params }) => {
4039
expectTypeOf(params).toEqualTypeOf<{ id: string }>()
41-
return HttpResponse.text('non-matching')
40+
return HttpResponse.text(
41+
// @ts-expect-error Response body doesn't match the response type.
42+
'non-matching',
43+
)
4244
}),
4345
)
4446
})
@@ -72,12 +74,12 @@ it('custom graphql resolver has correct variables and response type', () => {
7274
it('custom graphql resolver does not accept unknown variables', () => {
7375
graphql.query<{ number: number }, { id: string }>(
7476
'GetUser',
75-
// @ts-expect-error Incompatible response query type.
7677
identityGraphQLResolver(({ variables }) => {
7778
expectTypeOf(variables).toEqualTypeOf<{ id: string }>()
7879

7980
return HttpResponse.json({
8081
data: {
82+
// @ts-expect-error Incompatible response query type.
8183
user: {
8284
id: variables.id,
8385
},

‎test/typings/graphql.test-d.ts

+51-54
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,12 @@ it('graphql mutation allows explicit null as the response body type for the muta
3737
})
3838
})
3939
it('graphql mutation does not allow mismatched mutation response', () => {
40-
graphql.mutation<{ key: string }>(
41-
'MutateData',
42-
// @ts-expect-error Response data doesn't match the query type.
43-
() => {
44-
return HttpResponse.json({ data: {} })
45-
},
46-
)
40+
graphql.mutation<{ key: string }>('MutateData', () => {
41+
return HttpResponse.json({
42+
// @ts-expect-error Response data doesn't match the query type.
43+
data: {},
44+
})
45+
})
4746
})
4847

4948
it("graphql query does not accept null as variables' generic query type ", () => {
@@ -53,6 +52,7 @@ it("graphql query does not accept null as variables' generic query type ", () =>
5352
null
5453
>('', () => {})
5554
})
55+
5656
it("graphql query accepts the correct type for the variables' generic query type", () => {
5757
/**
5858
* Response body type (GraphQL query type).
@@ -76,15 +76,14 @@ it('graphql query allows explicit null as the response body type for the query',
7676
})
7777

7878
it('graphql query does not accept invalid data type for the response body type for the query', () => {
79-
graphql.query<{ id: string }>(
80-
'GetUser',
81-
// @ts-expect-error "id" type is incorrect
82-
() => {
83-
return HttpResponse.json({
84-
data: { id: 123 },
85-
})
86-
},
87-
)
79+
graphql.query<{ id: string }>('GetUser', () => {
80+
return HttpResponse.json({
81+
data: {
82+
// @ts-expect-error "id" type is incorrect
83+
id: 123,
84+
},
85+
})
86+
})
8887
})
8988

9089
it('graphql query does not allow empty response when the query type is defined', () => {
@@ -114,12 +113,12 @@ it("graphql operation does not accept null as variables' generic operation type"
114113
})
115114

116115
it('graphql operation does not allow mismatched operation response', () => {
117-
graphql.operation<{ key: string }>(
118-
// @ts-expect-error Response data doesn't match the query type.
119-
() => {
120-
return HttpResponse.json({ data: {} })
121-
},
122-
)
116+
graphql.operation<{ key: string }>(() => {
117+
return HttpResponse.json({
118+
// @ts-expect-error Response data doesn't match the query type.
119+
data: {},
120+
})
121+
})
123122
})
124123

125124
it('graphql operation allows explicit null as the response body type for the operation', () => {
@@ -140,64 +139,62 @@ it('graphql handlers allow passthrough responses', () => {
140139

141140
return HttpResponse.json({ data: {} })
142141
})
142+
})
143143

144-
it("graphql variables cannot extract type from the runtime 'DocumentNode'", () => {
145-
/**
146-
* Supports `DocumentNode` as the GraphQL operation name.
147-
*/
148-
const getUser = parse(`
144+
it("graphql variables cannot extract type from the runtime 'DocumentNode'", () => {
145+
/**
146+
* Supports `DocumentNode` as the GraphQL operation name.
147+
*/
148+
const getUser = parse(`
149149
query GetUser {
150150
user {
151151
firstName
152152
}
153153
}
154154
`)
155-
graphql.query(getUser, () => {
156-
return HttpResponse.json({
157-
// Cannot extract query type from the runtime `DocumentNode`.
158-
data: { arbitrary: true },
159-
})
155+
graphql.query(getUser, () => {
156+
return HttpResponse.json({
157+
// Cannot extract query type from the runtime `DocumentNode`.
158+
data: { arbitrary: true },
160159
})
161160
})
161+
})
162162

163-
it('graphql query cannot extract variable and reponse types', () => {
164-
const getUserById = parse(`
163+
it('graphql query cannot extract variable and reponse types', () => {
164+
const getUserById = parse(`
165165
query GetUserById($userId: String!) {
166166
user(id: $userId) {
167167
firstName
168168
}
169169
}
170170
`)
171-
graphql.query(getUserById, ({ variables }) => {
172-
variables.userId.toUpperCase()
173-
174-
// Extracting variables from the native "DocumentNode" is impossible.
175-
variables.foo
176-
177-
return HttpResponse.json({
178-
data: {
179-
user: {
180-
firstName: 'John',
181-
// Extracting a query body type from the "DocumentNode" is impossible.
182-
lastName: 'Maverick',
183-
},
171+
graphql.query(getUserById, ({ variables }) => {
172+
// Cannot extract variables type from a DocumentNode.
173+
expectTypeOf(variables).toEqualTypeOf<Record<string, any>>()
174+
175+
return HttpResponse.json({
176+
data: {
177+
user: {
178+
firstName: 'John',
179+
// Extracting a query body type from the "DocumentNode" is impossible.
180+
lastName: 'Maverick',
184181
},
185-
})
182+
},
186183
})
187184
})
185+
})
188186

189-
it('graphql mutation cannot extract variable and reponse types', () => {
190-
const createUser = parse(`
187+
it('graphql mutation cannot extract variable and reponse types', () => {
188+
const createUser = parse(`
191189
mutation CreateUser {
192190
user {
193191
id
194192
}
195193
}
196194
`)
197-
graphql.mutation(createUser, () => {
198-
return HttpResponse.json({
199-
data: { arbitrary: true },
200-
})
195+
graphql.mutation(createUser, () => {
196+
return HttpResponse.json({
197+
data: { arbitrary: true },
201198
})
202199
})
203200
})

‎test/typings/http.test-d.ts

+85-17
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,21 @@ import { http, HttpResponse, passthrough } from 'msw'
33

44
it('supports a single path parameter', () => {
55
http.get<{ id: string }>('/user/:id', ({ params }) => {
6-
params.id.toUpperCase()
7-
86
expectTypeOf(params).toEqualTypeOf<{ id: string }>()
97
})
108
})
119

1210
it('supports multiple path parameters', () => {
13-
http.get<{ a: string; b: string[] }>('/user/:a/:b/:b', ({ params }) => {
14-
params.a.toUpperCase()
15-
params.b.map((x) => x)
16-
17-
expectTypeOf(params).toEqualTypeOf<{ a: string; b: string[] }>()
11+
type Params = { a: string; b: string[] }
12+
http.get<Params>('/user/:a/:b/:b', ({ params }) => {
13+
expectTypeOf(params).toEqualTypeOf<Params>()
1814
})
1915
})
2016

2117
it('supports path parameters declared via type', () => {
22-
type UserPathParams = { id: string }
23-
http.get<UserPathParams>('/user/:id', ({ params }) => {
24-
params.id.toUpperCase()
25-
26-
expectTypeOf(params).toEqualTypeOf<UserPathParams>()
18+
type Params = { id: string }
19+
http.get<Params>('/user/:id', ({ params }) => {
20+
expectTypeOf(params).toEqualTypeOf<Params>()
2721
})
2822
})
2923

@@ -32,13 +26,11 @@ it('supports path parameters declared via interface', () => {
3226
id: string
3327
}
3428
http.get<PostPathParameters>('/user/:id', ({ params }) => {
35-
params.id.toUpperCase()
36-
3729
expectTypeOf(params).toEqualTypeOf<PostPathParameters>()
3830
})
3931
})
4032

41-
it('supports request body generic', () => {
33+
it('supports json as a request body generic argument', () => {
4234
http.post<never, { id: string }>('/user', async ({ request }) => {
4335
const data = await request.json()
4436

@@ -50,7 +42,7 @@ it('supports request body generic', () => {
5042
})
5143
})
5244

53-
it('supports null as the response body generic', () => {
45+
it('supports null as the request body generic', () => {
5446
http.get<never, null>('/user', async ({ request }) => {
5547
const data = await request.json()
5648
expectTypeOf(data).toEqualTypeOf<null>()
@@ -63,12 +55,77 @@ it('returns plain Response withouth explicit response body generic', () => {
6355
})
6456
})
6557

66-
it('supports response body generic', () => {
58+
it('supports string as a response body generic argument', () => {
59+
http.get<never, never, string>('/', ({ request }) => {
60+
if (request.headers.has('x-foo')) {
61+
return HttpResponse.text('conditional')
62+
}
63+
64+
return HttpResponse.text('hello')
65+
})
66+
})
67+
68+
it('supports exact string as a response body generic argument', () => {
69+
http.get<never, never, 'hello'>('/', () => {
70+
return HttpResponse.text('hello')
71+
})
72+
73+
http.get<never, never, 'hello'>('/', () => {
74+
// @ts-expect-error Non-matching response body type.
75+
return HttpResponse.text('unexpected')
76+
})
77+
})
78+
79+
it('supports object as a response body generic argument', () => {
6780
http.get<never, never, { id: number }>('/user', () => {
6881
return HttpResponse.json({ id: 1 })
6982
})
7083
})
7184

85+
it('supports narrow object as a response body generic argument', () => {
86+
http.get<never, never, { id: 123 }>('/user', () => {
87+
return HttpResponse.json({ id: 123 })
88+
})
89+
90+
http.get<never, never, { id: 123 }>('/user', () => {
91+
return HttpResponse.json({
92+
// @ts-expect-error Non-matching response body type.
93+
id: 456,
94+
})
95+
})
96+
})
97+
98+
it('supports object with extra keys as a response body generic argument', () => {
99+
type ResponseBody = {
100+
[key: string]: number | string
101+
id: 123
102+
}
103+
104+
http.get<never, never, ResponseBody>('/user', () => {
105+
return HttpResponse.json({
106+
id: 123,
107+
// Extra keys are allowed if they satisfy the index signature.
108+
name: 'John',
109+
})
110+
})
111+
112+
http.get<never, never, ResponseBody>('/user', () => {
113+
return HttpResponse.json({
114+
// @ts-expect-error Must be 123.
115+
id: 456,
116+
name: 'John',
117+
})
118+
})
119+
120+
http.get<never, never, ResponseBody>('/user', () => {
121+
return HttpResponse.json({
122+
id: 123,
123+
// @ts-expect-error Must satisfy the index signature.
124+
name: { a: 1 },
125+
})
126+
})
127+
})
128+
72129
it('supports response body generic declared via type', () => {
73130
type ResponseBodyType = { id: number }
74131
http.get<never, never, ResponseBodyType>('/user', () => {
@@ -127,3 +184,14 @@ it("accepts passthrough in HttpResponse's body", () => {
127184
return HttpResponse.json({ id: 1 })
128185
})
129186
})
187+
188+
it('infers a narrower json response type', () => {
189+
type ResponseBody = {
190+
a: number
191+
}
192+
193+
http.get<never, never, ResponseBody>('/', () => {
194+
// @ts-expect-error Unknown property "b".
195+
return HttpResponse.json({ a: 1, b: 2 })
196+
})
197+
})

0 commit comments

Comments
 (0)
Please sign in to comment.