本文讨论日常开发过程中遇到的一个 TypeScript 联合类型使用问题。
问题
以下是一段常见的数据请求处理代码,成功和失败对应不同的 Response
。
interface SuccessResponse {
data: string;
}
interface FailedResponse {
code: number;
}
function handleResponse(response: SuccessResponse | FailedResponse) {}
handleResponse({}); // Error
handleResponse({ data: 'foo' }); // OK
handleResponse({ code: 1 }); // OK
handleResponse({ data: 'foo', code: 1 }); // OK
其中 handleResponse({ data: 'foo', code: 1 })
传入了多余的参数,但 TypeScript 没有提示类型错误。因为 { data: string, code: number }
可以赋给 SuccessResponse | FailedResponse
类型,而不是我们期望的要么是 SuccessResponse
,要么是 FailedResponse
,二者互斥。
精确对象类型(Exact Object Types)
Flow 提供了一种目前 TypeScript 4.3 尚不支持的语法,可以通过 {| |}
定义精确对象类型(Exact Object Types),完美实现想要的效果。
// @flow
type SuccessResponse = {| data: string |};
type FailedResponse = {| code: number |};
function handleResponse(response: SuccessResponse | FailedResponse) {}
handleResponse({}); // Error
handleResponse({ data: 'foo' }); // OK
handleResponse({ code: 1 }); // OK
handleResponse({ data: 'foo', code: 1 }); // Error
TypeScript 目前有关于精确类型的讨论,见 Exact Types #12936。
可识别的联合类型(Discriminated Unions)
对 TypeScript 来说可以通过添加额外的字段识别联合类型。
interface SuccessResponse {
kind: 'success';
data: string;
}
interface FailedResponse {
kind: 'failed';
code: number;
}
function handleResponse(response: SuccessResponse | FailedResponse) {}
handleResponse({}); // Error
handleResponse({ kind: 'success', data: 'foo' }); // OK
handleResponse({ kind: 'failed', code: 1 }); // OK
handleResponse({ kind: 'success', data: 'foo', code: 1 }); // Error
这种情况需要同时修改类型定义和传参,不够优雅。
手动屏蔽多余属性
TypeScript 还可以通过将不存在的属性定义为 never
来进行屏蔽,避免使用。
interface SuccessResponse {
data: string;
code?: never;
}
interface FailedResponse {
code: number;
data?: never;
}
function handleResponse(response: SuccessResponse | FailedResponse) {}
handleResponse({}); // Error
handleResponse({ data: 'foo' }); // OK
handleResponse({ code: 1 }); // OK
handleResponse({ data: 'foo', code: 1 }); // Error
此时可以实现提示类型错误。
注意,用 never
的一个瑕疵是避免不了传 undefined
。
handleResponse({ data: undefined, code: 1 }); // OK
handleResponse({ data: 'foo', code: undefined }); // OK
半自动屏蔽多余属性
手动给不存在的属性添加 never
类型的做法太过繁琐,而且需要修改 SuccessResponse
和 FailedResponse
的原本定义,对此我们可以实现一个工具类型 Not
。
type Not<T> = {
[P in keyof T]?: never;
};
interface SuccessResponse {
data: string;
}
interface FailedResponse {
code: number;
}
type Response =
| (SuccessResponse & Not<FailedResponse>)
| (FailedResponse & Not<SuccessResponse>);
function handleResponse(response: Response) {}
handleResponse({}); // Error
handleResponse({ data: 'foo' }); // OK
handleResponse({ code: 1 }); // OK
handleResponse({ data: 'foo', code: 1 }); // Error
不相交的联合类型(Disjoint Unions)
还可以更进一步,自定义一个新工具类型来代替原始的联合类型(|
),可以称之为互斥的联合类型(Exclusive Unions),或者不相交的联合类型(Disjoint Unions)——对应数学中的不相交集合(Disjoint Sets)。
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;
interface SuccessResponse {
data: string;
}
interface FailedResponse {
code: number;
}
type Response = XOR<SuccessResponse, FailedResponse>;
function handleResponse(response: Response) {}
handleResponse({}); // Error
handleResponse({ data: 'foo' }); // OK
handleResponse({ code: 1 }); // OK
handleResponse({ data: 'foo', code: 1 }); // Error
这个工具类型,ts-essentials 和 type-fest 都有实现,在 type-fest 中它被命名为 MergeExclusive
。
TypeScript 目前有提案建议增加逻辑或 ^
操作符,如果实现,将不再需要手动处理。
思考
实际开发中,通过增加额外字段实现的所谓“可识别的联合类型”是最实用的,因为接口定义的结构应该稳定,业务处理成功、失败的状态始终由固定字段表示。同时我们也可以期待 TypeScript 实现“不相交的联合类型”和“精确对象类型”。