学习TypeScript:低维护类型

124 阅读6分钟

我写了很多关于TypeScript的文章,我非常享受它在日常工作中给我带来的好处。但我要承认,我不太喜欢写类型或类型注解。我很高兴TypeScript在编写普通的JavaScript时可以推断出我的用法,所以我不屑于写任何额外的东西。

这就是我写TypeScript的一般方式。我写普通的JavaScript,在TypeScript需要额外信息的地方,我很乐意添加一些额外的注释。有一个条件。我不想为维护类型而烦恼。我宁可创建能够在其依赖关系或周围环境发生变化时自我更新的类型。我把这种方法称为创建低维护类型

场景1:信息已经可用#

让我们来看看这个简短而可能不完整的复制功能。我想把文件从一个目录复制到另一个目录。为了使我的生活更轻松,我创建了一组默认的选项,这样我就不必过多地重复自己的工作。

const defaultOptions = {
  from: "./src",
  to: "./dest",
};

function copy(options) {
  // Let's merge default options and options
  const allOptions = { ...defaultOptions, ...options};

  // todo: Implementation of the rest
}

这是一个你可能在JavaScript中经常看到的模式。你立即看到的是,TypeScript错过了一些类型信息。尤其是copy 函数的options 参数,此刻是any 。所以,我们最好为它添加一个类型!

我可以做的一件事是明确地创建类型。

type Options = {
  from: string;
  to: string;
};

const defaultOptions: Options = {
  from: "./src",
  to: "./dest",
};

type PartialOptions = {
  from?: string;
  to?: string;
};

function copy(options: PartialOptions) {
  // Let's merge default options and options
  const allOptions = { ...defaultOptions, ...options};

  // todo: Implementation of the rest
}

这是一个非常合理的方法。你考虑类型,然后你分配类型,然后你得到你所习惯的所有编辑器反馈和类型检查。但是如果有什么变化呢?让我们假设我们在Options ,添加另一个字段,我们将不得不调整我们的代码三次。

type Options = {
  from: string;
  to: string;
+ overwrite: boolean;  
};

const defaultOptions: Options = {
  from: "./src",
  to: "./dest",
+ overwrite: true,
};

type PartialOptions = {
  from?: string;
  to?: string;
+ overwrite?: boolean;
};

但是为什么呢?这些信息已经在那里了!在defaultOptions ,我们准确地告诉TypeScript我们正在寻找的东西。让我们来优化。

  1. 删除PartialOptions ,使用实用类型Partial<T> ,以获得相同的效果。你可能已经猜到了这个
  2. 利用TypeScript中的typeof 操作符,快速创建一个新的类型。
const defaultOptions = {
  from: "./src",
  to: "./dest",
  overwrite: true,
};

function copy(options: Partial<typeof defaultOptions>) {
  // Let's merge default options and options
  const allOptions = { ...defaultOptions, ...options};

  // todo: Implementation of the rest
}

就这样吧。只是在我们需要告诉TypeScript我们正在寻找的地方进行注释。

  • 如果我们添加新的字段,我们根本不需要维护任何东西
  • 如果我们重命名一个字段,我们得到的只是我们关心的信息。所有使用copy ,我们必须改变我们传递给函数的选项
  • 我们有一个单一的真相来源:实际的defaultOptions 对象。这是最重要的对象,因为它是我们在运行时拥有的唯一信息。

而我们的代码也变得更加简洁。TypeScript变得不那么具有侵入性,并且与我们编写JavaScript的方式更加一致。

David指出了另一个属于这个类别的例子。通过const context、typeof 和索引访问操作符,你能够将一个元组转换为一个联盟。

const categories = [
  "beginner",
  "intermediate",
  "advanced",
] as const;

// "beginner" | "intermediate" | "advanced"
type Category = (typeof categories)[number]

同样,我们只保留了一个部分,即实际的数据。我们将categories ,转换成一个元组类型,并对每个元素进行索引。很好!

场景2:连接的模型#

不过,我并不反对将你的模型分层。相反,我认为在大多数情况下,对你的模型和你的数据进行明确和有意的描述是有意义的。让我们看一下这个玩具店。

type ToyBase = {
  name: string;
  price: number;
  quantity: number;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
}

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
}

type Doll = ToyBase & {
  kind: "doll";
  material: "plastic" | "plush";
}

type Toy = BoardGame | Puzzle | Doll;

这是一些伟大的数据建模。我们有一个适当的ToyBase ,其中包括所有不同的玩具类型的所有属性,如BoardGame,Puzzle, 和Doll 。通过kind 属性,我们可以创建一个独特的联合类型Toy ,在这里我们可以进行适当的区分。

function printToy(toy: Toy) {
  switch(toy.kind) {
    case "boardgame":
      // todo
      break;
    case "puzzle":
      // todo
      break;
    case "doll":
      // todo
      break;
    default: 
      console.log(toy);
  }
}

如果我们在不同的场景下需要这些模型的信息,我们可能最终会有更多的类型。

type ToyKind = "boardgame" | "puzzle" | "doll";

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
};

而这又是维护的开始。当我们添加一个类型VideoGame

type VideoGame = ToyBase & {
  kind: "videogame";
  system: "NES" | "SNES" | "Mega Drive" | "There are no more consoles"; 
};

我们必须在三个不同的地方进行维护。

- type Toy = BoardGame | Puzzle | Doll;
+ type Toy = BoardGame | Puzzle | Doll | VideoGame;

- type ToyKind = "boardgame" | "puzzle" | "doll";
+ type ToyKind = "boardgame" | "puzzle" | "doll" | "videogame";

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
+ videogame: Toy[];
};

这不仅需要大量的维护,而且还很容易出错。错字可能会发生,因为我可能在GroupedToys 中拼错了videogame 键,或者在ToyKind 联盟中拼错了字符串"videogame"

让我们使用TypeScript的一些内置功能来改变这种情况。我认为没有合理的方法来改变我们需要维护的第一个类型,Toy ,但这没关系。在这里,明确一点是好的,因为我们只想包括实际的玩具,而不是意外地具有相同的基本功能的东西。

如果我们想有一个包含所有可能的kind 类型的联合类型ToyKind ,最好不要在旁边维护它们,而是直接访问这些类型。

- type ToyKind = "boardgame" | "puzzle" | "doll";
+ type ToyKind = Toy["kind"]

这也是同样的技巧,由于我们创建了Toy 联盟。

我们可以使用新创建的和自我维护的ToyKind 类型来创建一个新的、更好的GroupedToys 类型,使用映射的类型。

type GroupedToys = {
  [Kind in ToyKind]: Toy[]
}

就这样!当我们用新的信息改变Toy 类型时,我们在ToyKindGroupedToys 中就有了更新的信息。对我们来说,要维护的东西更少了。

我们甚至可以走得更远。GroupedToys 类型并不完全是我们所寻找的。当我们对玩具进行分组时,我们要确保只将Doll 类型的对象添加到doll ,等等。所以我们需要的是再次分割联合。

Extract 类型为我们提供了一个很好的工具,正是为了做到这一点。

// GetKind extracts all types that have the kind property set to Kind
type GetKind<Group, Kind> = Extract<Group, { kind: Kind }>

type DebugOne = GetKind<Toy, "doll"> // DebugOne = Doll
type DebugTwo = GetKind<Toy, "puzzle"> // DebugTwo = Puzzle

让我们把它应用到GroupedToys

type GroupedToys = {
  [Kind in ToyKind]: GetKind<Toy, Kind>[]
};

// this is equal to 

type GroupedToys = {
  boardgame: BoardGame[];
  puzzle: Puzzle[];
  doll: Doll[];
}

很好!更好、更正确的类型,而且不需要维护!但有一件事还是让我很不爽。属性键。它们是单数。他们应该是复数。

type GroupedToys = {
  [Kind in ToyKind as `${Kind}s`]: GetKind<Toy, Kind>[]
};

// this is equal to 

type GroupedToys = {
  boardgames: BoardGame[];
  puzzles: Puzzle[];
  dolls: Doll[];
}

很好!再说一遍,对我们来说没有维护。当我们在Toy ,我们在所有其他类型中得到一个适当的更新。