Skip to content

Commit

Permalink
Improve TypeScript types with generic extend (#2353)
Browse files Browse the repository at this point in the history
  • Loading branch information
spence-s committed Jun 4, 2024
1 parent 4a44fc4 commit 15ca4a0
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 27 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"create-test-server": "^3.0.1",
"del-cli": "^5.1.0",
"delay": "^6.0.0",
"expect-type": "^0.19.0",
"express": "^4.19.2",
"form-data": "^4.0.0",
"formdata-node": "^6.0.3",
Expand All @@ -105,7 +106,8 @@
},
"ava": {
"files": [
"test/*"
"test/*",
"!test/*.types.ts"
],
"timeout": "10m",
"extensions": {
Expand Down
106 changes: 80 additions & 26 deletions source/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {Buffer} from 'node:buffer';
import type {Spread} from 'type-fest';
import type {CancelableRequest} from './as-promise/types.js';
import type {Response} from './core/response.js';
import type Options from './core/options.js';
Expand Down Expand Up @@ -69,14 +70,8 @@ export type ExtendOptions = {
mutableDefaults?: boolean;
} & OptionsInit;

export type OptionsOfTextResponseBody = Merge<OptionsInit, {isStream?: false; resolveBodyOnly?: false; responseType?: 'text'}>;
// eslint-disable-next-line @typescript-eslint/naming-convention
export type OptionsOfJSONResponseBody = Merge<OptionsInit, {isStream?: false; resolveBodyOnly?: false; responseType?: 'json'}>;
export type OptionsOfBufferResponseBody = Merge<OptionsInit, {isStream?: false; resolveBodyOnly?: false; responseType: 'buffer'}>;
export type OptionsOfUnknownResponseBody = Merge<OptionsInit, {isStream?: false; resolveBodyOnly?: false}>;
export type StrictOptions = Except<OptionsInit, 'isStream' | 'responseType' | 'resolveBodyOnly'>;
export type StreamOptions = Merge<OptionsInit, {isStream?: true}>;
type ResponseBodyOnly = {resolveBodyOnly: true};

export type OptionsWithPagination<T = unknown, R = unknown> = Merge<OptionsInit, {pagination?: PaginationOptions<T, R>}>;

Expand Down Expand Up @@ -142,26 +137,53 @@ export type GotPaginate = {
& (<T, R = unknown>(options?: OptionsWithPagination<T, R>) => Promise<T[]>);
};

export type GotRequestFunction = {
// `asPromise` usage
(url: string | URL, options?: OptionsOfTextResponseBody): CancelableRequest<Response<string>>;
<T>(url: string | URL, options?: OptionsOfJSONResponseBody): CancelableRequest<Response<T>>;
(url: string | URL, options?: OptionsOfBufferResponseBody): CancelableRequest<Response<Buffer>>;
(url: string | URL, options?: OptionsOfUnknownResponseBody): CancelableRequest<Response>;
export type OptionsOfTextResponseBody = Merge<StrictOptions, {isStream?: false; responseType?: 'text'}>;
export type OptionsOfTextResponseBodyOnly = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: true; responseType?: 'text'}>;
export type OptionsOfTextResponseBodyWrapped = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: false; responseType?: 'text'}>;

(options: OptionsOfTextResponseBody): CancelableRequest<Response<string>>;
<T>(options: OptionsOfJSONResponseBody): CancelableRequest<Response<T>>;
(options: OptionsOfBufferResponseBody): CancelableRequest<Response<Buffer>>;
(options: OptionsOfUnknownResponseBody): CancelableRequest<Response>;
export type OptionsOfJSONResponseBody = Merge<StrictOptions, {isStream?: false; responseType?: 'json'}>; // eslint-disable-line @typescript-eslint/naming-convention
export type OptionsOfJSONResponseBodyOnly = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: true; responseType?: 'json'}>; // eslint-disable-line @typescript-eslint/naming-convention
export type OptionsOfJSONResponseBodyWrapped = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: false; responseType?: 'json'}>; // eslint-disable-line @typescript-eslint/naming-convention

// `resolveBodyOnly` usage
(url: string | URL, options?: (Merge<OptionsOfTextResponseBody, ResponseBodyOnly>)): CancelableRequest<string>;
<T>(url: string | URL, options?: (Merge<OptionsOfJSONResponseBody, ResponseBodyOnly>)): CancelableRequest<T>;
(url: string | URL, options?: (Merge<OptionsOfBufferResponseBody, ResponseBodyOnly>)): CancelableRequest<Buffer>;
export type OptionsOfBufferResponseBody = Merge<StrictOptions, {isStream?: false; responseType?: 'buffer'}>;
export type OptionsOfBufferResponseBodyOnly = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: true; responseType?: 'buffer'}>;
export type OptionsOfBufferResponseBodyWrapped = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: false; responseType?: 'buffer'}>;

(options: (Merge<OptionsOfTextResponseBody, ResponseBodyOnly>)): CancelableRequest<string>;
<T>(options: (Merge<OptionsOfJSONResponseBody, ResponseBodyOnly>)): CancelableRequest<T>;
(options: (Merge<OptionsOfBufferResponseBody, ResponseBodyOnly>)): CancelableRequest<Buffer>;
export type OptionsOfUnknownResponseBody = Merge<StrictOptions, {isStream?: false}>;
export type OptionsOfUnknownResponseBodyOnly = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: true}>;
export type OptionsOfUnknownResponseBodyWrapped = Merge<StrictOptions, {isStream?: false; resolveBodyOnly: false}>;

export type GotRequestFunction<U extends ExtendOptions = Record<string, unknown>> = {
// `asPromise` usage
(url: string | URL, options?: OptionsOfTextResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest<string> : CancelableRequest<Response<string>>;
<T>(url: string | URL, options?: OptionsOfJSONResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest<T> : CancelableRequest<Response<T>>;
(url: string | URL, options?: OptionsOfBufferResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest<Buffer> : CancelableRequest<Response<Buffer>>;
(url: string | URL, options?: OptionsOfUnknownResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest : CancelableRequest<Response>;

(url: string | URL, options?: OptionsOfTextResponseBodyWrapped): CancelableRequest<Response<string>>;
<T>(url: string | URL, options?: OptionsOfJSONResponseBodyWrapped): CancelableRequest<Response<T>>;
(url: string | URL, options?: OptionsOfBufferResponseBodyWrapped): CancelableRequest<Response<Buffer>>;
(url: string | URL, options?: OptionsOfUnknownResponseBodyWrapped): CancelableRequest<Response>;

(url: string | URL, options?: OptionsOfTextResponseBodyOnly): CancelableRequest<string>;
<T>(url: string | URL, options?: OptionsOfJSONResponseBodyOnly): CancelableRequest<T>;
(url: string | URL, options?: OptionsOfBufferResponseBodyOnly): CancelableRequest<Buffer>;
(url: string | URL, options?: OptionsOfUnknownResponseBodyOnly): CancelableRequest;

(options: OptionsOfTextResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest<string> : CancelableRequest<Response<string>>;
<T>(options: OptionsOfJSONResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest<T> : CancelableRequest<Response<T>>;
(options: OptionsOfBufferResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest<Buffer> : CancelableRequest<Response<Buffer>>;
(options: OptionsOfUnknownResponseBody): U['resolveBodyOnly'] extends true ? CancelableRequest : CancelableRequest<Response>;

(options: OptionsOfTextResponseBodyWrapped): CancelableRequest<Response<string>>;
<T>(options: OptionsOfJSONResponseBodyWrapped): CancelableRequest<Response<T>>;
(options: OptionsOfBufferResponseBodyWrapped): CancelableRequest<Response<Buffer>>;
(options: OptionsOfUnknownResponseBodyWrapped): CancelableRequest<Response>;

(options: OptionsOfTextResponseBodyOnly): CancelableRequest<string>;
<T>(options: OptionsOfJSONResponseBodyOnly): CancelableRequest<T>;
(options: OptionsOfBufferResponseBodyOnly): CancelableRequest<Buffer>;
(options: OptionsOfUnknownResponseBodyOnly): CancelableRequest;

// `asStream` usage
(url: string | URL, options?: Merge<OptionsInit, {isStream: true}>): Request;
Expand Down Expand Up @@ -201,7 +223,7 @@ export type GotStream = GotStreamFunction & Record<HTTPAlias, GotStreamFunction>
/**
An instance of `got`.
*/
export type Got = {
export type Got<GotOptions extends ExtendOptions = ExtendOptions> = {
/**
Sets `options.isStream` to `true`.
Expand Down Expand Up @@ -274,5 +296,37 @@ export type Got = {
// x-unicorn: rainbow
```
*/
extend: (...instancesOrOptions: Array<Got | ExtendOptions>) => Got;
} & Record<HTTPAlias, GotRequestFunction> & GotRequestFunction;
extend<T extends Array<Got | ExtendOptions>>(...instancesOrOptions: T): Got<MergeExtendsConfig<T>>;
}
& Record<HTTPAlias, GotRequestFunction<GotOptions>>
& GotRequestFunction<GotOptions>;

export type ExtractExtendOptions<T> = T extends Got<infer GotOptions>
? GotOptions
: T;

/**
Merges the options of multiple Got instances.
*/
export type MergeExtendsConfig<Value extends Array<Got | ExtendOptions>> =
Value extends readonly [Value[0], ...infer NextValue]
? NextValue[0] extends undefined
? Value[0] extends infer OnlyValue
? OnlyValue extends ExtendOptions
? OnlyValue
: OnlyValue extends Got<infer GotOptions>
? GotOptions
: OnlyValue
: never
: ExtractExtendOptions<Value[0]> extends infer FirstArg extends ExtendOptions
? ExtractExtendOptions<NextValue[0] extends ExtendOptions | Got ? NextValue[0] : never> extends infer NextArg extends ExtendOptions
? Spread<FirstArg, NextArg> extends infer Merged extends ExtendOptions
? NextValue extends [NextValue[0], ...infer NextRest]
? NextRest extends Array<Got | ExtendOptions>
? MergeExtendsConfig<[Merged, ...NextRest]>
: never
: never
: never
: never
: never
: never;
76 changes: 76 additions & 0 deletions test/extend.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type {Buffer} from 'node:buffer';
import {expectTypeOf} from 'expect-type';
import got, {type CancelableRequest, type Response} from '../source/index.js';
import {type Got, type MergeExtendsConfig, type ExtractExtendOptions} from '../source/types.js';

// Ensure we properly extract the `extend` options from a Got instance which is used in MergeExtendsConfig generic
expectTypeOf<ExtractExtendOptions<Got<{resolveBodyOnly: false}>>>().toEqualTypeOf<{resolveBodyOnly: false}>();
expectTypeOf<ExtractExtendOptions<Got<{resolveBodyOnly: true}>>>().toEqualTypeOf<{resolveBodyOnly: true}>();
expectTypeOf<ExtractExtendOptions<{resolveBodyOnly: false}>>().toEqualTypeOf<{resolveBodyOnly: false}>();
expectTypeOf<ExtractExtendOptions<{resolveBodyOnly: true}>>().toEqualTypeOf<{resolveBodyOnly: true}>();

//
// Tests for MergeExtendsConfig - which merges the potential arguments of the `got.extend` method
//
// MergeExtendsConfig works with a single value
expectTypeOf<MergeExtendsConfig<[{resolveBodyOnly: false}]>>().toEqualTypeOf<{resolveBodyOnly: false}>();
expectTypeOf<MergeExtendsConfig<[{resolveBodyOnly: true}]>>().toEqualTypeOf<{resolveBodyOnly: true}>();
expectTypeOf<MergeExtendsConfig<[Got<{resolveBodyOnly: false}>]>>().toEqualTypeOf<{resolveBodyOnly: false}>();
expectTypeOf<MergeExtendsConfig<[Got<{resolveBodyOnly: true}>]>>().toEqualTypeOf<{resolveBodyOnly: true}>();

// MergeExtendsConfig merges multiple ExtendOptions
expectTypeOf<MergeExtendsConfig<[{resolveBodyOnly: false}, {resolveBodyOnly: true}]>>().toEqualTypeOf<{resolveBodyOnly: true}>();
expectTypeOf<MergeExtendsConfig<[{resolveBodyOnly: true}, {resolveBodyOnly: false}]>>().toEqualTypeOf<{resolveBodyOnly: false}>();

// MergeExtendsConfig merges multiple Got instances
expectTypeOf<MergeExtendsConfig<[Got<{resolveBodyOnly: false}>, Got<{resolveBodyOnly: true}>]>>().toEqualTypeOf<{resolveBodyOnly: true}>();
expectTypeOf<MergeExtendsConfig<[Got<{resolveBodyOnly: true}>, Got<{resolveBodyOnly: false}>]>>().toEqualTypeOf<{resolveBodyOnly: false}>();

// MergeExtendsConfig merges multiple Got instances and ExtendOptions with Got first argument
expectTypeOf<MergeExtendsConfig<[Got<{resolveBodyOnly: false}>, {resolveBodyOnly: true}]>>().toEqualTypeOf<{resolveBodyOnly: true}>();
expectTypeOf<MergeExtendsConfig<[Got<{resolveBodyOnly: true}>, {resolveBodyOnly: false}]>>().toEqualTypeOf<{resolveBodyOnly: false}>();

// MergeExtendsConfig merges multiple Got instances and ExtendOptions with ExtendOptions first argument
expectTypeOf<MergeExtendsConfig<[{resolveBodyOnly: true}, Got<{resolveBodyOnly: false}>]>>().toEqualTypeOf<{resolveBodyOnly: false}>();
expectTypeOf<MergeExtendsConfig<[{resolveBodyOnly: false}, Got<{resolveBodyOnly: true}>]>>().toEqualTypeOf<{resolveBodyOnly: true}>();

//
// Test the implementation of got.extend types
//
expectTypeOf(got.extend({resolveBodyOnly: false})).toEqualTypeOf<Got<{resolveBodyOnly: false}>>();
expectTypeOf(got.extend({resolveBodyOnly: true})).toEqualTypeOf<Got<{resolveBodyOnly: true}>>();
expectTypeOf(got.extend(got.extend({resolveBodyOnly: true}))).toEqualTypeOf<Got<{resolveBodyOnly: true}>>();
expectTypeOf(got.extend(got.extend({resolveBodyOnly: false}))).toEqualTypeOf<Got<{resolveBodyOnly: false}>>();
expectTypeOf(got.extend(got.extend({resolveBodyOnly: true}), {resolveBodyOnly: false})).toEqualTypeOf<Got<{resolveBodyOnly: false}>>();
expectTypeOf(got.extend(got.extend({resolveBodyOnly: false}), {resolveBodyOnly: true})).toEqualTypeOf<Got<{resolveBodyOnly: true}>>();
expectTypeOf(got.extend({resolveBodyOnly: true}, got.extend({resolveBodyOnly: false}))).toEqualTypeOf<Got<{resolveBodyOnly: false}>>();
expectTypeOf(got.extend({resolveBodyOnly: false}, got.extend({resolveBodyOnly: true}))).toEqualTypeOf<Got<{resolveBodyOnly: true}>>();

//
// Test that created instances enable the correct return types for the request functions
//
const gotWrapped = got.extend({});

// The following tests would apply to all of the method signatures (get, post, put, delete, etc...), but we only test the base function for brevity

// Test the default instance
expectTypeOf(gotWrapped('https://example.com')).toEqualTypeOf<CancelableRequest<Response<string>>>();
expectTypeOf(gotWrapped<{test: 'test'}>('https://example.com')).toEqualTypeOf<CancelableRequest<Response<{test: 'test'}>>>();
expectTypeOf(gotWrapped('https://example.com', {responseType: 'buffer'})).toEqualTypeOf<CancelableRequest<Response<Buffer>>>();

// Test the default instance can be overridden at the request function level
expectTypeOf(gotWrapped('https://example.com', {resolveBodyOnly: true})).toEqualTypeOf<CancelableRequest<string>>();
expectTypeOf(gotWrapped<{test: 'test'}>('https://example.com', {resolveBodyOnly: true})).toEqualTypeOf<CancelableRequest<{test: 'test'}>>();
expectTypeOf(gotWrapped('https://example.com', {responseType: 'buffer', resolveBodyOnly: true})).toEqualTypeOf<CancelableRequest<Buffer>>();

const gotBodyOnly = got.extend({resolveBodyOnly: true});

// Test the instance with resolveBodyOnly as an extend option
expectTypeOf(gotBodyOnly('https://example.com')).toEqualTypeOf<CancelableRequest<string>>();
expectTypeOf(gotBodyOnly<{test: 'test'}>('https://example.com')).toEqualTypeOf<CancelableRequest<{test: 'test'}>>();
expectTypeOf(gotBodyOnly('https://example.com', {responseType: 'buffer'})).toEqualTypeOf<CancelableRequest<Buffer>>();

// Test the instance with resolveBodyOnly as an extend option can be overridden at the request function level
expectTypeOf(gotBodyOnly('https://example.com', {resolveBodyOnly: false})).toEqualTypeOf<CancelableRequest<Response<string>>>();
expectTypeOf(gotBodyOnly<{test: 'test'}>('https://example.com', {resolveBodyOnly: false})).toEqualTypeOf<CancelableRequest<Response<{test: 'test'}>>>();
expectTypeOf(gotBodyOnly('https://example.com', {responseType: 'buffer', resolveBodyOnly: false})).toEqualTypeOf<CancelableRequest<Response<Buffer>>>();

0 comments on commit 15ca4a0

Please sign in to comment.