最近在做一个项目时,我发现了一些有趣的东西:TypeScript 的 infer
关键字可以推断出很多东西,它提供了一种更直接的方式来实现对嵌套类型的解包。
这个想是由 crosswalk 库激发的,它可以帮助你构建和使用类型安全的 REST API,使用infer
也可以在这种上下文中工作,但是当我们看 OpenAPI Schema 时,差异会更加明显。
假设你的API允许你创建一个用户(它也允许你GET一个用户,但我只在这里显示POST,因为这已经很详细了)。
{
"openapi": "3.0.3",
"info": { "title": "Users API", "version": "0.1" },
"paths": {
"/users": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Newly-created User",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreateUserRequest": {
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "number" }
},
"required": [ "name", "age" ]
},
"User": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"age": { "type": "number" }
},
"required": [ "id", "name", "age" ]
}
}
}
}
你可以通过 openapi-typescript 运行这个来生成 TypeScript 类型:
/** This file was auto-generated by openapi-typescript. */
export interface paths {
"/users": {
post: {
requestBody: {
content: {
"application/json": components["schemas"]["CreateUserRequest"];
};
};
responses: {
/** @description Newly-created User */
200: {
content: {
"application/json": components["schemas"]["User"];
};
};
};
};
// also get, etc.
};
}
export interface components {
schemas: {
CreateUserRequest: {
name: string;
age: number;
};
User: {
id: string;
name: string;
age: number;
};
};
}
通过 path
结构访问这些类型涉及到一整串索引:
type UserResponse = paths["/users"]["post"]["responses"][200]["content"]["application/json"];
如果你想做一个通用的 POST 方法,你很快就会遇到一些错误:
declare function post<Path extends keyof paths>(
endpoint: Path
): Promise<paths[Path]["post"]["responses"][200]["content"]["application/json"]>;
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '"application/json"' cannot be used to index type 'paths[Path]["post"]["responses"][200]["content"]'. (2536)
这个错误是有道理的:TypeScript没有理由相信这个深层索引操作序列将工作在任意类型上。
你可以使用 infer
来提取你想要的结构:
type HttpVerb = 'get' | 'post' | 'patch' | 'delete' | 'update';
type ResponseForMethod<Path extends keyof paths, Verb extends HttpVerb> =
paths[Path] extends Record<Verb, {
responses: {
200: {
content: {
'application/json': infer ResponseType, // <-- the "infer"
}
}
}
}> ? ResponseType : never;
declare function post<Path extends keyof paths>(
endpoint: Path
): Promise<ResponseForMethod<Path, 'post'>>;
const response = post('/users');
// ^? const response: Promise<{ id: string; name: string; age: number; }>
令我惊讶(并印象深刻)的是 infer
可以直接看到 Record
帮助器,后者是映射类型的包装器。
如果端点不支持 POST
请求,你将得到一个 never
类型返回,一个类型错误会更好,但希望 never
将足以提醒调用者,有些地方出错了。
我以前的的话会建议用这个帮助器链代替:
type LooseKey<T, K> = T[K & keyof T];
type LooseKey2<T, K1, K2> = LooseKey<LooseKey<T, K1>, K2>;
type LooseKey3<T, K1, K2, K3> = LooseKey<LooseKey2<T, K1, K2>, K3>;
type LooseKey4<T, K1, K2, K3, K4> = LooseKey<LooseKey3<T, K1, K2, K3>, K4>;
type LooseKey5<T, K1, K2, K3, K4, K5> = LooseKey<LooseKey4<T, K1, K2, K3, K4>, K5>;
type ResponseForMethod<Path extends keyof paths, Verb extends HttpVerb> =
LooseKey5<paths[Path], Verb, 'responses', 200, 'content', 'application/json'>;
但是与这个交集形式相比,infer
方法更直接、更易读、更紧密地匹配数据的布局。而且如果您需要从一个类型中提取多个信息,那么如何做就很清楚了。这是一个不错的胜利。
“把你拥有的和 TypeScript 想要的相互交叉”仍然是一个有用的技术吗?当你把一个类型参数传递给另一个泛型类型时,你有时需要写 & string
:
// see previous post
type ExtractRouteParams<Path extends string> = ...;
// e.g. ExtractRouteParams<'/users/:userId'> = {userId: string}
class ApiWrapper<API> {
apiGet<Path extends keyof API>(
path: Path,
queryParams: ExtractRouteParams<Path>,
// ~~~~
// Type 'Path' does not satisfy the constraint 'string'.
// Type 'keyof API' is not assignable to type 'string'.
// Type 'string | number | symbol' is not assignable to type 'string'.
// Type 'number' is not assignable to type 'string'. (2344)
): Promise<GetResponseForMethod<API, Path>>;
}
查看TypeScript Splits the Atom中 ExtractRouteParams
的定义,问题是我们期望 keyof API
是 string
的子类型,但严格来说 TypeScript 知道的是它是 PropertyKey
的子类型,也就是 string | number | symbol
,没有什么阻止 API
成为 string[]
,例如,在这种情况下 keyof API = number
,因为 ExtractRouteParams
期望一个 string
。
最好的解决方法是告诉 TypeScript API
应该只有 string
键,但我不知道如何做到这一点:写ApiWrapper<API extends Record<string, any>>
会导致同样的错误。
在这种情况下,使用交集来消除错误仍然有意义:
class ApiWrapper<API> {
apiGet<Path extends keyof API>(
path: Path,
queryParams: ExtractRouteParams<Path & string>, // ok
): Promise<GetResponseForMethod<API, Path>>;
}
当你使用嵌套对象类型时,请记住,你可以使用 infer
从它们内部提取特定的类型。
欢迎关注公众号:文本魔术,了解更多