DanLevy.net

TypeScript中的游击类型

反叛字体设计

Hero image for TypeScript中的游击类型

TypeScript 中的非常规类型

在本文中,我们将探讨三种有趣(可能很糟糕?)的技巧来辅助类型设计!

主要目标是实现一致可预测的 Model/Entity/Class 接口。

类型设计的方法

你可能已经遇到过或编写过关于“类型实现”的各种模式。尤其是在消费第三方 API 的数据时。

注意: 我故意忽略了构建实体关系图(ERD)或面向对象编程(OOP)继承层次结构的“传统”过程。在这里,我们构建类型来表示半结构化的 API 数据。

让我们探讨两种高层方法:单个大对象(自上而下)与多个命名类型(自下而上)。

单个大对象

优先考虑显式性而非可复用性和 DRY 原则。

额外好处: IDE/开发体验很棒,因为工具提示包含更完整的预览——无需麻烦。

interface ProductDetails {
name: string;
seller: { name: string };
availability: Array<{ warehouseId: string; quantity: number }>;
reviews: Array<{ authorId: number; stars: number }>;
}

由于我们优先考虑显式可读性,因此可以(在合理范围内)容忍一些重复。当属性组重复多次时,可以自由地将重复字段提取到命名类型中。

多个命名类型

优先考虑可复用性和 DRY 原则。

这种方法很可能是大多数人的首选方法。

interface ProductDetails {
name: string;
seller: Seller;
reviews: Reviews[];
availability: Availability[];
}
interface Seller { name: string; }
interface Availability { warehouseId: string; quantity: number; }
interface Reviews { authorId: number; stars: number; }

总的来说,这种方法很好。但它并非没有缺点。

⚠️ 自(大约)TypeScript v3起,语言服务器会截断工具提示,省略嵌套属性。 💡 有一些技巧可以稍微改善这种情况。尝试按住 Cmd 或 Ctrl,然后悬停在各种类型名称上——你应该能在工具提示中看到至少多一“层”的属性。

为什么我们必须在两种方法之间做出选择?(大类型 vs. 命名子类型。)

技巧一:为何不兼得

我们能否兼得?

✅ 是的!🎉

export interface ProductDetails {
name: string;
seller: { name: string };
reviews: Array<{ authorId: number; stars: number }>;
availability: Array<{ warehouseId: string; quantity: number }>;
}
export type Seller = ProductDetails["seller"];
export type Review = ProductDetails["reviews"][number];
export type Availability = ProductDetails["availability"][number];
  1. 创建大型的“主要”结构化类型。
  2. 导出从主要类型派生的子类型。

这种方法在“高层”对象受益于在一个地方进行文档化的系统中特别有用。 此外,这种技术支持在多种用例之间复用:模型、服务、查询结果等。

技巧二:混入(Mix-ins)

这种策略的核心是组合正确的字段,使用正确的名称,来表示单个逻辑对象。目标是利用 TypeScript 工具类型和类型联合高效地处理多个用例。

这种方法不同于传统的面向对象继承和层次结构,后者旨在将对象分层为紧密绑定的分类体系。混入方法关注扁平且松散相关的类型,在减少重复的同时对相关字段进行分组。

混入示例

interface TodoModel {
text: string;
complete: boolean;
}
interface InstanceMixin {
id: number;
}
/** TodoDraft 表示表单状态,可能全部为 undefined */
export type TodoDraft = Partial<TodoModel>;
/** Todo 表示来自数据库的 Todo 实例记录 */
export type Todo = TodoModel & InstanceMixin;

示例 User

interface User {
id: number;
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}

让我们表示 User 在保存到数据库之前和之后的状态。

// 核心 User 字段(例如用于 <form>)
interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
// 来自数据库的字段
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
// 一个 User **实例**——包含所有字段
type UserInstance = InstanceMixin & UserBase;

现在我们可以精确雕琢所需的字段(例如 password 用于创建/更新,但不包含在 UserInstance 的查询中)。

interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
/** 用于注册的 User 载荷,包含 `password` 字段 */
export type UserPayload = UserBase & { password: string };
/** 表示服务器返回的 User 类型 */
export type UserInstance = UserBase & InstanceMixin;
  1. “这是好实践吗?”
  2. “我应该试试吗?”

不知道。继续吧!

技巧三:使用命名空间组织

这里,我们声明一个 ModelMixins 命名空间。这提供了一些组织性,以及更清晰的重用模式。

标准化形状

// `src/types/mixins.d.ts`
namespace ModelMixins {
interface Identity {
id: number;
}
interface Timestamp {
createdAt: Date;
updatedAt: Date;
}
type Instance = ModelMixins.Identity & ModelMixins.Timestamp;
interface HashedPassword {
passwordHash: string;
}
interface InputPassword {
password: string;
}
}

使用类型联合

// `src/types/user.d.ts`
export interface UserBase {
name: string;
bio: string;
social: Record<"facebook" | "instagram" | "github", URL>;
}
// 单个 `User` 类型,使用类型联合动态表示创建前和创建后的状态
export type User =
| (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword)
| (UserBase & ModelMixins.InputPassword);

如果需要,你也可以导出单独的具名类型:

/** 用于注册的 User 载荷,包含 `password` 字段 */
export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;
/** 表示服务器返回的 User 类型 */
export type UserInstance = UserBase & ModelMixins.InputPassword;

实际使用

这是一个 upsert() 函数,它使用 in 运算符来区分 UserInstanceUserPayload 类型。

function upsert(user: User) {
if ("id" in user) {
// TypeScript 知道这里的 `user` 包含 Instance 的字段(id, createdAt 等)
return updateUser(user.id, user);
} else {
// TypeScript 知道这必须是 `UserBase & ModelMixins.InputPassword` 版本的 user
return createUser(user);
}
}

总结

我们介绍了三种技巧以及一些相关的辅助思路。

你可能会问,这些是好模式吗?我应该采纳其中一些想法吗?

资源