Skip to content

Commit f76c7cd

Browse files
authoredAug 17, 2024··
Add JSON generics to ky() and HTTPError (#619)
1 parent a7204c4 commit f76c7cd

File tree

9 files changed

+78
-29
lines changed

9 files changed

+78
-29
lines changed
 

‎readme.md

+36-6
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ It's just a tiny package with no dependencies.
5757
- URL prefix option
5858
- Instances with custom defaults
5959
- Hooks
60-
- TypeScript niceties (e.g. `.json()` resolves to `unknown`, not `any`; `.json<T>()` can be used too)
60+
- TypeScript niceties (e.g. `.json()` supports generics and defaults to `unknown`, not `any`)
6161

6262
## Install
6363

@@ -120,13 +120,33 @@ import ky from 'https://esm.sh/ky';
120120

121121
### ky(input, options?)
122122

123-
The `input` and `options` are the same as [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), with some exceptions:
124-
125-
- The `credentials` option is `same-origin` by default, which is the default in the spec too, but not all browsers have caught up yet.
126-
- Adds some more options. See below.
123+
The `input` and `options` are the same as [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), with additional `options` available (see below).
127124

128125
Returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response) with [`Body` methods](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body) added for convenience. So you can, for example, call `ky.get(input).json()` directly without having to await the `Response` first. When called like that, an appropriate `Accept` header will be set depending on the body method used. Unlike the `Body` methods of `window.Fetch`; these will throw an `HTTPError` if the response status is not in the range of `200...299`. Also, `.json()` will return an empty string if body is empty or the response status is `204` instead of throwing a parse error due to an empty body.
129126

127+
```js
128+
import ky from 'ky';
129+
130+
const user = await ky('/api/user').json();
131+
132+
console.log(user);
133+
```
134+
135+
⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `.json()`.
136+
137+
```ts
138+
import ky from 'ky';
139+
140+
// user1 is unknown
141+
const user1 = await ky('/api/users/1').json();
142+
// user2 is a User
143+
const user2 = await ky<User>('/api/users/2').json();
144+
// user3 is a User
145+
const user3 = await ky('/api/users/3').json<User>();
146+
147+
console.log([user1, user2, user3]);
148+
```
149+
130150
### ky.get(input, options?)
131151
### ky.post(input, options?)
132152
### ky.put(input, options?)
@@ -136,13 +156,21 @@ Returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/R
136156

137157
Sets `options.method` to the method name and makes a request.
138158

159+
⌨️ **TypeScript:** Accepts an optional type parameter for use with JSON responses (see [`ky()`](#kyinput-options)).
160+
161+
#### input
162+
163+
Type: `string` | `URL` | `Request`
164+
165+
Same as [`fetch` input](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#input).
166+
139167
When using a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance as `input`, any URL altering options (such as `prefixUrl`) will be ignored.
140168

141169
#### options
142170

143171
Type: `object`
144172

145-
In addition to all the [`fetch` options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options), it supports these options:
173+
Same as [`fetch` options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options), plus the following additional options:
146174

147175
##### method
148176

@@ -597,6 +625,8 @@ try {
597625
}
598626
```
599627

628+
⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `error.response.json()`.
629+
600630
### TimeoutError
601631

602632
The error thrown when the request times out. It has a `request` property with the [`Request` object](https://developer.mozilla.org/en-US/docs/Web/API/Request).

‎source/core/Ky.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export class Ky {
5353
ky._decorateResponse(response);
5454

5555
if (!response.ok && ky._options.throwHttpErrors) {
56-
let error = new HTTPError(response, ky.request, (ky._options as unknown) as NormalizedOptions);
56+
let error = new HTTPError(response, ky.request, ky._options as NormalizedOptions);
5757

5858
for (const hook of ky._options.hooks.beforeError) {
5959
// eslint-disable-next-line no-await-in-loop

‎source/errors/HTTPError.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ import type {NormalizedOptions} from '../types/options.js';
22
import type {KyRequest} from '../types/request.js';
33
import type {KyResponse} from '../types/response.js';
44

5-
// eslint-lint-disable-next-line @typescript-eslint/naming-convention
6-
export class HTTPError extends Error {
7-
public response: KyResponse;
5+
export class HTTPError<T = unknown> extends Error {
6+
public response: KyResponse<T>;
87
public request: KyRequest;
98
public options: NormalizedOptions;
109

‎source/types/ResponsePromise.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Returns a `Response` object with `Body` methods added for convenience. So you ca
33
*/
44
import {type KyResponse} from './response.js';
55

6-
export type ResponsePromise = {
6+
export type ResponsePromise<T = unknown> = {
77
arrayBuffer: () => Promise<ArrayBuffer>;
88

99
blob: () => Promise<Blob>;
@@ -30,10 +30,12 @@ export type ResponsePromise = {
3030
value: number;
3131
}
3232
33-
const result = await ky(…).json<Result>();
33+
const result1 = await ky(…).json<Result>();
34+
// or
35+
const result2 = await ky<Result>(…).json();
3436
```
3537
*/
36-
json: <T = unknown>() => Promise<T>;
38+
json: <J = T>() => Promise<J>;
3739

3840
text: () => Promise<string>;
39-
} & Promise<KyResponse>;
41+
} & Promise<KyResponse<T>>;

‎source/types/ky.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -19,47 +19,47 @@ export type KyInstance = {
1919
//=> `{data: '🦄'}`
2020
```
2121
*/
22-
(url: Input, options?: Options): ResponsePromise;
22+
<T>(url: Input, options?: Options): ResponsePromise<T>;
2323

2424
/**
2525
Fetch the given `url` using the option `{method: 'get'}`.
2626
2727
@param url - `Request` object, `URL` object, or URL string.
2828
@returns A promise with `Body` methods added.
2929
*/
30-
get: (url: Input, options?: Options) => ResponsePromise;
30+
get: <T>(url: Input, options?: Options) => ResponsePromise<T>;
3131

3232
/**
3333
Fetch the given `url` using the option `{method: 'post'}`.
3434
3535
@param url - `Request` object, `URL` object, or URL string.
3636
@returns A promise with `Body` methods added.
3737
*/
38-
post: (url: Input, options?: Options) => ResponsePromise;
38+
post: <T>(url: Input, options?: Options) => ResponsePromise<T>;
3939

4040
/**
4141
Fetch the given `url` using the option `{method: 'put'}`.
4242
4343
@param url - `Request` object, `URL` object, or URL string.
4444
@returns A promise with `Body` methods added.
4545
*/
46-
put: (url: Input, options?: Options) => ResponsePromise;
46+
put: <T>(url: Input, options?: Options) => ResponsePromise<T>;
4747

4848
/**
4949
Fetch the given `url` using the option `{method: 'delete'}`.
5050
5151
@param url - `Request` object, `URL` object, or URL string.
5252
@returns A promise with `Body` methods added.
5353
*/
54-
delete: (url: Input, options?: Options) => ResponsePromise;
54+
delete: <T>(url: Input, options?: Options) => ResponsePromise<T>;
5555

5656
/**
5757
Fetch the given `url` using the option `{method: 'patch'}`.
5858
5959
@param url - `Request` object, `URL` object, or URL string.
6060
@returns A promise with `Body` methods added.
6161
*/
62-
patch: (url: Input, options?: Options) => ResponsePromise;
62+
patch: <T>(url: Input, options?: Options) => ResponsePromise<T>;
6363

6464
/**
6565
Fetch the given `url` using the option `{method: 'head'}`.

‎source/types/request.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,6 @@ type CombinedRequestInit = globalThis.RequestInit & UndiciRequestInit;
6060

6161
export type RequestInitRegistry = {[K in keyof CombinedRequestInit]-?: true};
6262

63-
export type KyRequest = {
64-
json: <T = unknown>() => Promise<T>;
63+
export type KyRequest<T = unknown> = {
64+
json: <J = T>() => Promise<J>;
6565
} & Request;

‎source/types/response.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export type KyResponse = {
2-
json: <T = unknown>() => Promise<T>;
1+
export type KyResponse<T = unknown> = {
2+
json: <J = T >() => Promise<J>;
33
} & Response;

‎test/http-error.ts

+19-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import test from 'ava';
2+
import {expectTypeOf} from 'expect-type';
23
import {HTTPError} from '../source/index.js';
34
import {type Mutable} from '../source/utils/types.js';
45

@@ -14,7 +15,7 @@ function createFakeResponse({status, statusText}: {status?: number; statusText?:
1415

1516
test('HTTPError handles undefined response.statusText', t => {
1617
const status = 500;
17-
// @ts-expect-error missing Request
18+
// @ts-expect-error missing options
1819
const error = new HTTPError(
1920
// This simulates the case where a browser Response object does
2021
// not define statusText, such as IE, Safari, etc.
@@ -27,7 +28,7 @@ test('HTTPError handles undefined response.statusText', t => {
2728
});
2829

2930
test('HTTPError handles undefined response.status', t => {
30-
// @ts-expect-error missing Request
31+
// @ts-expect-error missing options
3132
const error = new HTTPError(
3233
// This simulates a catastrophic case where some unexpected
3334
// response object was sent to HTTPError.
@@ -39,7 +40,7 @@ test('HTTPError handles undefined response.status', t => {
3940
});
4041

4142
test('HTTPError handles a response.status of 0', t => {
42-
// @ts-expect-error missing Request
43+
// @ts-expect-error missing options
4344
const error = new HTTPError(
4445
// Apparently, it's possible to get a response status of 0.
4546
createFakeResponse({statusText: undefined, status: 0}),
@@ -48,3 +49,18 @@ test('HTTPError handles a response.status of 0', t => {
4849

4950
t.is(error.message, 'Request failed with status code 0: GET invalid:foo');
5051
});
52+
53+
test('HTTPError provides response.json()', async t => {
54+
// @ts-expect-error missing options
55+
const error = new HTTPError<{foo: 'bar'}>(
56+
new Response(JSON.stringify({foo: 'bar'})),
57+
new Request('invalid:foo'),
58+
);
59+
60+
const responseJson = await error.response.json();
61+
62+
expectTypeOf(responseJson).toEqualTypeOf<{foo: 'bar'}>();
63+
64+
t.true(error.response instanceof Response);
65+
t.deepEqual(responseJson, {foo: 'bar'});
66+
});

‎test/main.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ test('.json() when response is chunked', async t => {
245245
response.end(']');
246246
});
247247

248-
const responseJson = await ky.get(server.url).json();
248+
const responseJson = await ky.get<['one', 'two']>(server.url).json();
249+
250+
expectTypeOf(responseJson).toEqualTypeOf<['one', 'two']>();
249251

250252
t.deepEqual(responseJson, ['one', 'two']);
251253

@@ -831,7 +833,7 @@ test('parseJson option with response.json()', async t => {
831833

832834
const responseJson = await response.json<{hello: string; extra: string}>();
833835

834-
expectTypeOf(responseJson).toMatchTypeOf({hello: 'world', extra: 'extraValue'});
836+
expectTypeOf(responseJson).toEqualTypeOf({hello: 'world', extra: 'extraValue'});
835837

836838
t.deepEqual(responseJson, {
837839
...json,

0 commit comments

Comments
 (0)
Please sign in to comment.