วิธีเพิ่มฟิลด์ตามค่าประเภททั่วไปใน TypeScript?
ฉันกำลังพยายามทำไลบรารี API ฝั่งหน้าเล็ก ๆ สำหรับโครงการของฉันโดยมีแนวคิดเพื่อเรียนรู้ระบบประเภทของ TypeScript
สมมติว่าฉันกำหนดประเภทยูเนียนที่มีเมธอด REST:
type Methods = 'POST' | 'GET' | 'PATCH' | 'DELETE' | 'PUT';
และว่าฉันได้ผูกส่วนหัวกับวิธีการโดยใช้ส่วนต่อประสานดังต่อไปนี้:
interface QueryOptions<Method extends Methods = 'GET'> {
headers?: RequestInit['headers'];
method?: Method;
}
ซึ่ง RequestInit['headers'] มีชนิดเดียวกับ headers ที่ใช้โดย fetch API ของเบราว์เซอร์
ตอนนี้ body เกิดขึ้นเฉพาะในคำขอ POST ดังนั้นฉันต้องการฟังก์ชันที่สามารถสรุปโดยอัตโนมัติได้ว่าจำเป็นต้องใช้ body หรือจำเป็นต้องใช้ params
ฉันคิดออกว่า:
type QueryFactory = <M extends Methods>(
queryOptions: QueryOptions<M>,
) => <T, P extends M extends 'POST' ? Record<string, unknown> : Record<string, string>>(options: {
id?: string | number;
params?: M extends 'POST' ? never : P;
body: M extends 'POST' ? P : never;
route: string;
}) => Promise<T>;
ถ้าฉันนำอินเทอร์เฟซนั้นไปใช้งานกับสิ่งบางอย่างเช่น
const queryParams = <T extends Record<string, string>>(url: string, params?: T): string =>
params && Object.keys(params).length > 0 ? `${url}?${new URLSearchParams(params).toString()}` : url;
const queryFactory: QueryFactory = (options) => async ({ route, id, ...rest }) => {
route = id ? `${route}/${id}` : route;
const input =
options.method !== `POST` && typeof rest.params !== `undefined` ? queryParams(route, rest.params) : route;
const init = options.method === `POST` ? { ...options, body: JSON.stringify(rest.body) } : options;
const response = await fetch(input, init);
return response.json();
};
ด้วยสิ่งนี้ ฉันสามารถทำได้ตอนนี้:
const query = {
post: queryFactory({ method: `POST` }),
get: queryFactory({ method: `GET` }),
put: queryFactory({ method: `PUT` }),
patch: queryFactory({ method: `PATCH` }),
delete: queryFactory({ method: `DELETE` }),
};
const books = query.get<{ books: string[] }, { author: string }>({ route: `/books`, params: { author: 'Foo Bar' } });
ฉันคิดว่าประเภท QueryFactory ของฉันผิดเพราะบรรทัดโค้ดสุดท้าย query.get แจ้งว่าไม่มีฟิลด์ body: never และ queryParams(route, rest.params) แจ้งว่า rest.params อาจสืบทอดมาจาก Record<string, unknown> ในขณะที่จริง ๆ ต้องสืบทอดจาก Record<string, string> เท่านั้น
ฉันกำลังถึงขีดจำกัดของความรู้ระบบประเภทของ TypeScript ของฉันและไม่เห็นว่าจะสามารถแก้ไขปัญหาการอนุมานนี้ได้อย่างไร ฉันได้คัดลอกรหัสไปยัง: TypeScript PlayGround
คำตอบ #1
Typescript มีชนิดที่ทรงพลังที่เรียกว่าชนิดแยก ซึ่งช่วยให้ TS สามารถแยกความแตกต่างโดยอัตโนมัติระหว่างชนิดใดชนิดหนึ่งจากหลายชนิดหากคุณมีค่าฟิลด์ที่ทราบในเวลาคอมไพล์
ในกรณีนี้ คุณสามารถเริ่มต้นโดยการกำหนดอินเตอร์เฟซฐานที่มีคุณสมบัติทั่วไป จากนั้นสร้างซับอินเตอร์เฟซที่มีความแตกต่าง (หมายเหตุ: โดยทั่วไป TS ไม่จำเป็นต้องให้ชนิดทั้งหมดสืบทอดจากฐานร่วม สิ่งที่สำคัญคือชนิดทั้งหมดมีฟิลด์จำแนกพร้อมค่าที่ชัดเจน)
type Method = 'POST' | 'GET' | 'PATCH' | 'DELETE' | 'PUT';
interface BaseQueryOptions<M extends Method> {
method?: M
headers?: RequestInit['headers']
id?: string
route: string
}
type BodyType = Record<string, unknown>
type ParamsType = Record<string, string>
interface PostOptions<T extends BodyType> extends BaseQueryOptions<'POST'> {
body: T
}
interface DefaultOptions<T extends ParamsType> extends BaseQueryOptions<'GET' | 'PATCH' | 'DELETE' | 'PUT'> {
params?: T
}
จากนั้นเรากำหนดยูเนียน (ที่นี่เรียกว่าเพียง Options) เป็นประเภทในยูเนียนนี้เองที่ TS จะทำการแยกประเภทให้เรา เราก็ยังต้องมียูทิลิตี้ไทป์บางตัวสำหรับการพิมพ์ที่เข้มงวดด้วย
type BodyOrParams = BodyType | ParamsType
type AsBodyType<T> = T extends BodyType ? T : never
type AsParamsType<T> = T extends ParamsType ? T : never
type Options<P extends BodyOrParams> = PostOptions<AsBodyType<P>> | DefaultOptions<AsParamsType<P>>
ตอนนี้ เราสามารถเขียนฟังก์ชันที่รับ Options และ TypeScript สามารถสร้าง type guards อัตโนมัติได้ นั่นคือมันสามารถสรุปประเภทโดยอัตโนมัติเมื่อเราตรวจสอบค่าของ method ในคำสั่ง if หรือ switch
async function query<T extends Record<string, unknown>, P extends BodyOrParams > (options: Options<P>): Promise<T> {
let { route, id } = options
route = id ? `${route}/${id}` : route;
switch (options.method) {
case 'POST':
// TS knows here that options is PostOptions
const postOptions = { ...options, body: JSON.stringify(options.body) }
return (await fetch(route, postOptions)).json()
case 'GET':
case 'PATCH':
case 'DELETE':
case 'PUT':
// TS knows here that options is DefaultOptions
const params = queryParams(route, options.params)
return (await fetch(params, options)).json()
default:
throw 'No method specified'
}
}
และตอนนี้เรามีการพิมพ์ที่เข้มงวดมากเมื่อเรียกใช้ query:
query<{ books: string[] }, { author: string }>(
{
method: 'GET',
route: `/books`,
params: { author: 'Foo Bar' }
}); // Works
query<{ books: string[] }, { author: string }>({
method: 'GET',
route: `/books`,
params: { foo: 'Foo Bar' }
}); // ERROR 'foo' does not exist on { author: string }
query<any, { book: any }>({
method: 'POST',
route: `/books`,
body: { book: 'Foo Bar' }
}); // works
query<any, { book: any }>({
method: 'POST',
route: `/books`,
params: { book: 'Foo Bar' }
}); // ERROR params does not exist on PostOptions
query<any, { book: any }>({
method: 'POST',
route: `/books`,
body: { author: 'Foo Bar' }
}); // ERROR author does not exist on { book: any }
ลิงก์ playground ของ Typescript
คำตอบ #2
never ไม่ใช่สิ่งที่คุณต้องการที่นี่ คุณต้องการลบคุณสมบัติของ Object options ดังนั้นบางอย่างเช่น Omit
ดังนั้นสมมติว่าเรามีอ็อบเจ็กต์ options
interface Options<P>{
id?: string | number;
params?: P;
body: P;
route: string;
}
เราสามารถได้วัตถุที่แตกต่างกันโดยใช้ omit จากสิ่งนั้นเช่น
type PostOption<P> = Omit<Options<P>, 'params'>
type GetOptions<P> = Omit<Options<P>, 'body'>
และจากนั้นประกาศการกำหนดฟังก์ชัน
declare function queryFactory<M extends Methods>(
queryOptions: QueryOptions<M>,
): <T, K extends M extends 'POST' ? Record<string, unknown> : Record<string, string>>
(options: M extends 'POST' ? PostOption<K> : GetOptions<K>) => Promise<K>
PlaygroundLink
