# 12-可串联构造器

# 题目描述

在 JavaScript 中我们经常会使用可串联(Chainable/Pipeline)的函数构造一个对象,但在 TypeScript 中,你能合理的给它赋上类型吗?

在这个挑战中,你可以使用任意你喜欢的方式实现这个类型 - Interface, Type 或 Class 都行。你需要提供两个函数 option(key, value)get()。在 option 中你需要使用提供的 key 和 value 扩展当前的对象类型,通过 get 获取最终结果。

例如

declare const config: Chainable;

const result = config
  .option('foo', 123)
  .option('name', 'type-challenges')
  .option('bar', { value: 'Hello World' })
  .get();

// 期望 result 的类型是:
interface Result {
  foo: number;
  name: string;
  bar: {
    value: string;
  };
}

你只需要在类型层面实现这个功能 - 不需要实现任何 TS/JS 的实际逻辑。

你可以假设 key 只接受字符串而 value 接受任何类型,你只需要暴露它传递的类型而不需要进行任何处理。同样的 key 只会被使用一次。

# 分析

这个题目虽然看着长,但是也算是比较贴合平时的工作了。一般而言,静下心来仔细分析就可以得到结果了。

从题目看,目标的类型至少具备两个属性,optionget

其中 optionget 都是函数,但是 option 的返回,还是一个类似的结构,只不过新的类型中 get 的返回值多了一些属性。可以写出如下的类型:

type Chainable<T = {}> = {
  option: <K extends string, S>(
    key: K,
    value: S,
  ) => Chainable<
    {
      [P in keyof T]: T[P];
    } & {
      [P in K]: S;
    }
  >;
  get(): T;
};

option 的返回值通过递归构造一个新增了属性后的新的 Chainable 类型。

这里需要注意的就是 KS 的写法和位置。

但是这样写并不能通过本题目的用例:

const result3 = a
  .option('name', 'another name')
  // @ts-expect-error
  .option('name', 123)
  .get()

Expect<Alike<typeof result3, Expected3>>,

type Expected3 = {
  name: number
}

首先,对于传入相同属性值的地方,需要提醒用户输入非法,同时虽然输入非法,但是类型要以最后输入的为准(这就是用例的要求,实际并无太大意义)。要想达到和题目一样的效果,就需要:

  1. 限制入参的类型,不能为 keyof T
  2. 修改 option 出参的类型,以最后一次为准

第一点比较容易做到,只需要 (k: K extends keyof T ? never : K) 即可限制,第二点需要绕个弯,因为 ts 中 & 类型,并不能后者覆盖前者,假设 A & B, 那么当 A 和 B 具有同一属性名时,其属性值也会合并,如下:

type Case1 = { a: number } & { a: string };

// Case2: never
type Case2 = Case1['a'];

type Merge<T> = {
  [P in keyof T]: T[P];
};

// Case3 = { a: never }
type Case3 = Merge<Case1>;

在 Case2 中,never。这里肯定有同学要问,为什么需要 Merge 下才会出现 never,别问,问就是就是这样,这也算是一个规则吧,A & B, ts 在代码提醒的时候并不会展示合并的结果,只有 Merge 一份的时候,才能正确的展示交叉后的结果。更深层次的探讨就不在范畴了,记住即可。

所以要想实现题目的效果,就需要避免交叉。而是在交叉前在原属性里面排除掉要增加的属性即可。直接看答案。

# 题解

type Chainable<T = {}> = {
  option<K extends string, S>(
    key: K extends keyof T ? never : K,
    value: S,
  ): Chainable<
    {
      // 核心,从原来的 T 中排除 K 属性,这样交叉后的结果就是传入的 S 属性
      [P in keyof T as P extends K ? never : P]: T[P];
    } & {
      [P in K]: S;
    }
  >;
  get(): T;
};

核心就是上述的注释,将 { [P in keyof T as P extends K ? never : P] : T[P]} 替换成 Omit 也是一样的效果,本质就是剔除当前类型中,属性为 K 的元素,可以查看 实现 Omit 一节。

# 知识点

  1. 递归解决嵌套问题
  2. 实现 Omit
Last Updated: 2023/5/16 06:00:28