“不相交的联合类型”与“精确对象类型”

本文讨论日常开发过程中遇到的一个 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 类型的做法太过繁琐,而且需要修改 SuccessResponseFailedResponse 的原本定义,对此我们可以实现一个工具类型 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-essentialstype-fest 都有实现,在 type-fest 中它被命名为 MergeExclusive

TypeScript 目前有提案建议增加逻辑或 ^ 操作符,如果实现,将不再需要手动处理。

思考

实际开发中,通过增加额外字段实现的所谓“可识别的联合类型”是最实用的,因为接口定义的结构应该稳定,业务处理成功、失败的状态始终由固定字段表示。同时我们也可以期待 TypeScript 实现“不相交的联合类型”和“精确对象类型”。

相关链接