Skip to content

TypeScript 高级类型:创建部分可选属性的工具类型

1. 问题场景

在 TypeScript 开发中,我们经常遇到这样的情况:一个数据对象的创建类型(Create)与它的基本类型(Article)非常相似,但某些属性在创建时是可选的。

例如,我们有一个 Article 接口:

typescript
interface Article {
  title: string;
  content: string;
  author: string;
  date: Date;
  readCount: number;
}

在创建一个 createArticle 函数时,我们希望 authordatereadCount 是可选的。如果手动编写这个创建选项的类型,会产生大量重复代码:

typescript
// 创建文章时,作者、日期、阅读量是可选的
interface CreateArticleOptions {
  title: string;      // 必选
  content: string;    // 必选
  author?: string;    // 可选
  date?: Date;        // 可选
  readCount?: number; // 可选
}

这种方式不仅繁琐,而且当 Article 接口更新时,我们还需要手动同步更新 CreateArticleOptions,非常不利于维护。

2. 解决方案:自定义 Optional 工具类型

理想的解决方案是能根据 Article 类型自动“演算”出 CreateArticleOptions 类型。我们可以创建一个自定义的工具类型 Optional<T, K> 来实现这个目标。

  • T: 原始类型(如 Article)。
  • K: 需要从 T 中挑选出来并设为可选的属性名集合(如 'author' | 'date' | 'readCount')。

实现代码

这个强大的工具类型只需要一行代码即可实现:

typescript
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

使用示例

有了 Optional 工具,我们可以像下面这样生成 CreateArticleOptions

typescript
// 原始文章类型
interface Article {
  title: string;
  content: string;
  author: string;
  date: Date;
  readCount: number;
}

// 1. 定义工具类型 Optional
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

// 2. 使用 Optional 生成创建选项类型
type CreateArticleOptions = Optional<Article, 'author' | 'date' | 'readCount'>;

/*
CreateArticleOptions 的推断结果等同于:
{
  title: string;
  content: string;
} & {
  author?: string;
  date?: Date;
  readCount?: number;
}

最终合并为:
{
  title: string;
  content: string;
  author?: string;
  date?: Date;
  readCount?: number;
}
*/

// 3. 在函数中使用
function createArticle(options: CreateArticleOptions) {
  // ...
}

这样,CreateArticleOptions 就和 Article 建立了关联,当 Article 变化时,它也能自动更新,大大提升了代码的可维护性。

3. 核心知识点:内置工具类型解析

要理解 Optional 的工作原理,需要先掌握几个 TypeScript 内置的核心工具类型和一个关键的类型操作符。

3.1. 交叉类型 (Intersection Types): &

交叉类型 & 用于将多个类型合并为一个新类型。新类型将拥有所有成员类型的所有属性。

typescript
interface A {
  name: string;
}

interface B {
  age: number;
}

type C = A & B;
// C 的类型为: { name: string; age: number; }

const person: C = { name: "Alice", age: 30 };

3.2. Omit<T, K>

Omit<T, K>(缺省)会创建一个新类型,该类型拥有 T 的所有属性,但移除了指定的属性 K

typescript
interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

// 移除 'description' 和 'completed' 属性
type TodoPreview = Omit<Todo, 'description' | 'completed'>;

// TodoPreview 的类型为: { title: string; }
const todo: TodoPreview = { title: "Clean room" };

3.3. Pick<T, K>

Pick<T, K>(挑选)会创建一个新类型,该类型只包含从 T 中挑选出来的属性 K

typescript
interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

// 只挑选 'title' 和 'completed' 属性
type TodoInfo = Pick<Todo, 'title' | 'completed'>;

// TodoInfo 的类型为: { title: string; completed: boolean; }
const todo: TodoInfo = { title: "Buy groceries", completed: false };

3.4. Partial<T>

Partial<T>(部分)会创建一个新类型,该类型将 T 的所有属性都变为可选的。

typescript
interface Todo {
  title: string;
  description: string;
}

// 将所有属性变为可选
type PartialTodo = Partial<Todo>;

// PartialTodo 的类型为: { title?: string; description?: string; }
const update: PartialTodo = { title: "Learn TypeScript" };

4. Optional<T, K> 实现原理详解

了解了上述基础知识后,我们再来拆解 Optional 的实现:

type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

整个过程分为两部分,最后通过 & 合并:

  1. Omit<T, K>:保留必选属性

    • 这一部分从原始类型 T 中移除了我们希望设为可选的属性 K
    • 结果是一个只包含所有必选属性的新类型。
    • 示例Omit<Article, 'author' | 'date' | 'readCount'> 的结果是 { title: string; content: string; }
  2. Partial<Pick<T, K>>:创建可选属性

    • 这部分由内到外执行:
      • Pick<T, K>:首先,从原始类型 T 中挑选出那些我们希望设为可选的属性 K
        • 示例Pick<Article, 'author' | 'date' | 'readCount'> 的结果是 { author: string; date: Date; readCount: number; }
      • Partial<...>:然后,使用 Partial 将上一步挑选出的所有属性都变为可选。
        • 示例Partial<...> 应用后的结果是 { author?: string; date?: Date; readCount?: number; }
  3. &:合并结果

    • 最后,使用交叉类型 & 将第一步得到的“必选属性”类型和第二步得到的“可选属性”类型合并起来。
    • 最终结果
      typescript
      { title: string; content: string; } & { author?: string; date?: Date; readCount?: number; }
      合并后即为我们想要的最终类型:
      typescript
      {
        title: string;
        content: string;
        author?: string;
        date?: Date;
        readCount?: number;
      }

通过这种组合方式,我们仅用一行代码就实现了一个灵活、可复用且功能强大的高级工具类型,完美解决了最初的问题。