TypeScript:如何基于Endpoint的decoder属性确定call函数返回类型?
我尝试编写一个类型安全的API包装器,核心是call()函数,它接收一个endpoint对象,该对象描述目标端点、请求体类型(若存在)和预期响应体类型(若存在)。当endpoint.decoder(类型为Decoder<ResType>)存在时,call()应返回Promise<ResType>;否则返回Promise<void>。
给定类型定义
type Encoder<MsgType extends object> = { encode(msg: MsgType): Uint8Array; }; type Decoder<MsgType extends object> = { decode(bin: Uint8Array): MsgType; }; type Endpoint<ReqType extends object | void = void, ResType extends object | void = void> = { path: string; decoder: ResType extends object ? Decoder<ResType> : never; } & ( | { method: 'PUT' | 'POST'; encoder: ReqType extends object ? Encoder<ReqType> : never; } | { method: 'GET'; encoder: never } );
encoder和decoder属性的存在决定了端点是否需要请求体或响应体(GET请求明确不允许请求体)。
编写的call()函数及错误
async function call<ReqType extends object | void, ResType extends object | void>( endpoint: Endpoint<ReqType, ResType>, message?: ReqType ): Promise<ResType> { let body: BodyInit | undefined; if (endpoint.encoder && message) { body = endpoint.encoder.encode(message); // 此处无问题 } const response = await fetch('/api' + endpoint.path, { method: endpoint.method, body }) if (endpoint.decoder) { const data = await response.arrayBuffer(); /* 错误:Type 'object' is not assignable to type 'ResType'. 'object' is assignable to the constraint of type 'ResType', but 'ResType' could be instantiated with a different subtype of constraint 'void | object'. */ return endpoint.decoder.decode(new Uint8Array(data)); } else { /* 错误:Type 'void' is not assignable to type 'ResType'. 'void' is assignable to the constraint of type 'ResType', but 'ResType' could be instantiated with a different subtype of constraint 'void | object'. */ return Promise.resolve(); } }
疑问
既然endpoint.decoder的类型是ResType extends object ? Decoder<ResType> : never,那检查endpoint.decoder是否存在,难道不应该断言ResType确实是object类型(而非void)吗?为什么还会出现类型错误?
解决方案
问题根源在于TypeScript的控制流分析无法直接将endpoint.decoder的存在性与泛型参数ResType的类型分支关联起来。虽然decoder的类型依赖于ResType是否为object,但TypeScript不能自动推导这个关联,需要我们手动细化类型。
方案1:使用类型断言明确分支类型
在两个返回分支中,分别用类型断言明确返回值的类型,匹配ResType的实际分支:
async function call<ReqType extends object | void, ResType extends object | void>( endpoint: Endpoint<ReqType, ResType>, message?: ReqType ): Promise<ResType> { let body: BodyInit | undefined; if (endpoint.encoder && message) { body = endpoint.encoder.encode(message); } const response = await fetch('/api' + endpoint.path, { method: endpoint.method, body }) if (endpoint.decoder) { const data = await response.arrayBuffer(); // 此时ResType必然是object类型,断言为ResType return endpoint.decoder.decode(new Uint8Array(data)) as ResType; } else { // 此时ResType必然是void类型,转换后断言为Promise<ResType> return Promise.resolve() as unknown as Promise<ResType>; } }
方案2:自定义类型守卫细化泛型
定义一个自定义类型守卫,帮助TypeScript识别decoder存在时ResType的类型:
// 类型守卫:判断endpoint是否带有decoder,此时ResType必为非void的object类型 function hasDecoder<ResType extends object | void>( endpoint: Endpoint<any, ResType> ): endpoint is Endpoint<any, Exclude<ResType, void>> { return !!endpoint.decoder; } async function call<ReqType extends object | void, ResType extends object | void>( endpoint: Endpoint<ReqType, ResType>, message?: ReqType ): Promise<ResType> { let body: BodyInit | undefined; if (endpoint.encoder && message) { body = endpoint.encoder.encode(message); } const response = await fetch('/api' + endpoint.path, { method: endpoint.method, body }) if (hasDecoder(endpoint)) { const data = await response.arrayBuffer(); // TypeScript已推断出ResType是object类型,无需额外断言 return endpoint.decoder.decode(new Uint8Array(data)); } else { // TypeScript已推断出ResType是void类型 return Promise.resolve() as Promise<ResType>; } }
方案3:重构Endpoint为明确联合类型
将Endpoint拆分为更清晰的联合类型,让TypeScript的控制流分析更容易识别类型分支:
type BaseEndpoint = { path: string }; // GET请求端点:无encoder,decoder可选(依赖ResType是否为object) type GetEndpoint<ResType extends object | void = void> = BaseEndpoint & { method: 'GET'; encoder: never; decoder: ResType extends object ? Decoder<ResType> : never; }; // PUT/POST请求端点:必有encoder,decoder可选(依赖ResType是否为object) type MutateEndpoint<ReqType extends object, ResType extends object | void = void> = BaseEndpoint & { method: 'PUT' | 'POST'; encoder: Encoder<ReqType>; decoder: ResType extends object ? Decoder<ResType> : never; }; // 重构后的Endpoint联合类型 type Endpoint<ReqType extends object | void = void, ResType extends object | void = void> = ReqType extends void ? GetEndpoint<ResType> : MutateEndpoint<ReqType, ResType>;
重构后,call()函数无需额外断言即可通过类型检查,TypeScript能自动识别不同分支的返回类型。
内容的提问来源于stack exchange,提问作者60N102W

