mcpField
D
dry-types

dry-types

github.com/marius-townhouse开发645 次安装39 周前更新
代码仓库

关于 dry-types

一项 TypeScript 开发技能,介绍如何用类型操作和泛型减少类型定义重复。它帮助开发者保持相关类型同步,同时避免仅因结构相似就进行抽象。

主要功能

  1. 01使用 `extends` 扩展类型
  2. 02用 `Pick` 和 `Omit` 选取属性
  3. 03使用 `Partial`、`keyof` 和 `typeof`
  4. 04通过映射类型和工具类型派生类型
  5. 05仅抽象语义相关的类型

适用场景

  1. 01从较大的类型派生子集
  2. 02保持相关类型定义同步
  3. 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