关于 dry-types
一项 TypeScript 开发技能,介绍如何用类型操作和泛型减少类型定义重复。它帮助开发者保持相关类型同步,同时避免仅因结构相似就进行抽象。
主要功能
- 01使用 `extends` 扩展类型
- 02用 `Pick` 和 `Omit` 选取属性
- 03使用 `Partial`、`keyof` 和 `typeof`
- 04通过映射类型和工具类型派生类型
- 05仅抽象语义相关的类型
适用场景
- 01从较大的类型派生子集
- 02保持相关类型定义同步
- 03创建可选或扩展的类型变体
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill dry-types安装后在 Claude Code 中输入/dry-types确认已加载。
详细介绍
使用类型操作和泛型类型避免重复
概述
对类型应用 DRY(不要重复自己),而不仅仅是对代码。
类型重复会导致与代码重复相同的问题:不一致、维护负担和 bug。使用 TypeScript 的类型操作从其他类型派生类型。
何时使用此技能
- 在接口之间复制字段
- 多个类型共享公共属性
- 想要一个类型成为另一个类型的子集
- 希望类型自动保持同步
- 需要创建类型的可选/部分版本
核心原则
NEVER copy-paste type definitions. Derive types from a single source of truth.
请记住:
- 使用
extends添加字段 - 使用
Pick<T, K>选择字段 - 使用
Partial<T>将字段设为可选 - 使用
keyof获取键类型 - 使用
typeof从值派生类型
检测:复制类型问题
如果发现相似类型逐渐产生差异:
// ❌ Duplicated type definitions
interface Person {
firstName: string;
lastName: string;
}
interface PersonWithBirthDate {
firstName: string; // Duplicated!
lastName: string; // Duplicated!
birth: Date;
}
如果你给 Person 添加了 middleName 呢?现在它们就不同步了。
基本技巧
使用 extends 添加字段
// ✅ Derive from base type
interface Person {
firstName: string;
lastName: string;
}
interface PersonWithBirthDate extends Person {
birth: Date;
}
使用 Pick 选择字段
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
// ✅ Select only the fields you need
type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;
使用 Partial 创建可选版本
interface Options {
width: number;
height: number;
color: string;
}
class UIWidget {
constructor(init: Options) { /* ... */ }
// ✅ All fields optional for updates
update(options: Partial<Options>) { /* ... */ }
}
使用 keyof 获取键类型
type OptionsKeys = keyof Options;
// ^? type OptionsKeys = "width" | "height" | "color"
使用 typeof 从值派生类型
const DEFAULTS = {
width: 640,
height: 480,
color: '#00FF00',
};
// ✅ Type derived from value
type Options = typeof DEFAULTS;
// ^? type Options = { width: number; height: number; color: string; }
标准库工具类型
| 工具类型 | 用途 | 示例 |
|---|---|---|
Pick<T, K> | 选择属性 | Pick<User, 'id' | 'name'> |
Omit<T, K> | 移除属性 | Omit<User, 'password'> |
Partial<T> | 将所有属性设为可选 | Partial<Config> |
Required<T> | 将所有属性设为必需 | Required<PartialConfig> |
Readonly<T> | 将所有属性设为只读 | Readonly<State> |
ReturnType<F> | 函数返回类型 | ReturnType<typeof fn> |
Parameters<F> | 函数参数 | Parameters<typeof fn> |
高级模式
映射类型
// Create optional version manually
type OptionsUpdate = {
[K in keyof Options]?: Options[K]
};
// Equivalent to Partial<Options>
索引联合类型
interface SaveAction { type: 'save'; /* ... */ }
interface LoadAction { type: 'load'; /* ... */ }
type Action = SaveAction | LoadAction;
// ✅ Extract discriminant type
type ActionType = Action['type'];
// ^? type ActionType = "save" | "load"
使用 ReturnType 获取函数结果
function getUserInfo(userId: string) {
return {
userId,
name,
age,
// ... many fields
};
}
// ✅ Derive type from function
type UserInfo = ReturnType<typeof getUserInfo>;
使用 as 重新映射键
interface ShortToLong {
q: 'search';
n: 'numberOfResults';
}
// Invert the mapping
type LongToShort = {
[K in keyof ShortToLong as ShortToLong[K]]: K
};
// ^? type LongToShort = { search: "q"; numberOfResults: "n"; }
同态映射类型
使用 keyof 时,映射类型会保留修饰符:
interface Customer {
/** How the customer would like to be addressed. */
title?: string;
/** Complete name as entered in the system. */
readonly name: string;
}
// ✅ Preserves optional and readonly
type PickTitle = Pick<Customer, 'title'>;
// ^? type PickTitle = { title?: string; }
type PickName = Pick<Customer, 'name'>;
// ^? type PickName = { readonly name: string; }
何时不要应用 DRY
不要抽取仅仅是偶然相似的类型:
// ❌ Don't do this - coincidental similarity
interface NamedAndIdentified {
id: number;
name: string;
}
interface Product extends NamedAndIdentified {
priceDollars: number;
}
interface Customer extends NamedAndIdentified {
address: string;
}
为什么不这样做? Product.id 和 Customer.id 在语义上不同:
- Customer.id 可能会变成 UUID
- Product.name 和 Customer.name 的演变方向可能不同
经验法则: 如果无法给它起一个有意义的名称,那它很可能是过早抽象。
常见模式
基础类型 + 扩展类型
// Shared base
interface Vertebrate {
weightGrams: number;
color: string;
isNocturnal: boolean;
}
// Specific extensions
interface Bird extends Vertebrate {
wingspanCm: number;
}
interface Mammal extends Vertebrate {
eatsGardenPlants: boolean;
}
输入/输出类型
// Full type
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
// Input type derived
type CreateUserInput = Pick<User, 'email' | 'name'>;
// Or with Omit
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt'>>;
函数签名
// ✅ Factor out common signatures
type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
const get: HTTPFunction = (url, opts) => { /* ... */ };
const post: HTTPFunction = (url, opts) => { /* ... */ };
抗压应对方案
1. “复制粘贴更快”
压力:“直接复制类型吧,这样更快。”
回应: 技术债务会不断累积,类型也会逐渐产生差异。
行动: 花时间使用 extends、Pick 或其他派生方式。
2. “这些类型差别够大”
压力:“它们只是碰巧共享了字段。”
回应: 说得有道理——先确认它们在语义上是否相同。
行动: 只抽取代表同一概念的类型。
警示信号——停下来重新考虑
- 复制粘贴接口字段
- 多个类型拥有完全相同的属性子集
- 更新了一个类型,却忘了更新另一个
- 名称为“...WithX”的类型重复了基础类型
常见借口(都不成立)
| 借口 | 事实 |
|---|---|
| “就几个字段” | 几个字段 × 多个类型 = 维护噩梦 |
| “我会记得同时更新两边” | 你不会,队友也不会 |
| “派生关系让人困惑” | 这总比排查类型差异容易 |
快速参考
// Adding fields
interface Extended extends Base { newField: T; }
// Selecting fields
type Subset = Pick<Full, 'a' | 'b'>;
// Removing fields
type WithoutPassword = Omit<User, 'password'>;
// Making optional
type Updates = Partial<Options>;
// Getting return type
type Result = ReturnType<typeof myFunction>;
// Getting key union
type Keys = keyof MyInterface;
总结
从单一事实来源派生类型。
使用 TypeScript 的类型操作(extends、Pick、Partial、keyof、typeof、映射类型)表达类型之间的关系。这样可以让类型保持同步并减少维护负担。但只有在类型语义相关时才应用 DRY,而不仅仅是结构相似时。
参考
基于 Dan Vanderkam 所著的《Effective TypeScript》第 15 条:使用类型操作和泛型类型,避免重复编写。
评论
支持 Markdown