Skip to content

可串联构造器

1403字约5分钟

2022-12-01

题目

Github: Chainable options

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

在这个挑战中,你可以使用任意你喜欢的方式实现这个类型 - Interface, TypeClass 都行。 你需要提供两个函数 option(key, value)get()。 在 option 中你需要使用提供的 keyvalue 扩展当前的对象类型,通过 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 只会被使用一次。

解题思路

这是一个实用性非常高的挑战。我们很容易会想到可以在 webpack-chain(此库以归档不再维护) 中使用它。

在这个挑战中,我们需要实现 options(key, value)get() 两个方法。 在每次调用 options(key, value) 方法时,需要累加 keyvalue 的类型信息, 累加操作需要持续进行,直到 get() 被调用,返回最终的类型信息。

我们从最基础的类型开始,定义一个 Chainable 接口,它包含了 option 方法和 get 方法。

type Chainable = {
  option(key: any, value: any): any
  get(): any
}

首先我们需要得到 options(key, value) 的类型信息,这里我们可以使用 类型参数替换 any, 以便 TypeScript 可以推断出它们的类 型并将其分配给类型参数。

option(key, value) 需要被连续调用,因此,option(key, value) 需要返回 Chainable 类型本身。

type Chainable = {
  option<K, V>(key: K, value: V): Chainable
  get(): any
}

TypeScript 会将 key 推断 为字符串字面量类型,而将 value 推断为常见的类型。 例如,调用 option('foo', 123) 将得出的类型为:key = 'foo'value = number

接下来,我们需要保存 keyvalue 的类型信息,它必须在能够在连续调用 option 方法后能够保存 其状态,因此我们可以把这些信息保存在 Chainable 的类型参数中。

type Chainable<T = {}> = {
  option<K, V>(key: K, value: V): Chainable<T & { [P in K]: V }>
  get(): any
}

这里我们使用 交叉类型,将 T 的类型信息和 keyvalue 的类型信息进行了合并, 最终得到的类型是:T & { [P in K]: V }

但在这里我们还需要对 K 进行类型检查,因为 K 的类型可能不是字符串字面量类型。

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

接下来,我们需要在 get 方法 返回 T 的类型信息。

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

到这一步,已经基本可以满足挑战的要求了。

但我们还可以进一步思考,当连续调用option(key, value) 时传入的 相同的 key ,又或者 给相同的 key 传入 不同类型的 value 时,会发生什么情况?

declare const 
config
:
Chainable
const
result
=
config
.
option
('name', 'foo') // step 1
.
option
('name', 'bar') // step 2
.
option
('name', 123) // step 3
.
get
()

很明显可以看到,属性 name 的值类型被合并为 string | number

我们不妨增加一些挑战难度, 不允许合并 key 值的类型,重复调用的 key 的值类型应该与上一次调用的值类型保持一致,允许值被覆盖。

这里需要对 K 进行类型检查,当 K 满足 keyof T 约束,即在 T 类型上已存在 K 这个属性时, 我们需要对 V 也进行类型检查,V 的类型是否可以分配给 T[K],如果可以的话,那么 K 就可以作为 key, 否则应该返回 never,抛出错误。(由于 never 不能给其他类型使用,此时传入其他类型都会报错)

K extends keyof T ? (V extends T[K] ? K : never) : K

还需要对 option() 的返回类型进行检查,如果 K 满足 keyof T 约束时,需要先 T 上移除 K 这个属性, 然后再和 V 进行交叉类型,最终得到的类型是:Omit<T, K> & { [P in K]: V }。 而不满足 keyof T 约束时,直接和 V 进行交叉类型,最终得到的类型是:T & { [P in K]: V }

答案

type Chainable<T extends Record<string, unknown> = {}> = {
  option: <K extends string, V>(
    key: K extends keyof T ? (V extends T[K] ? K : never) : K,
    value: V,
  ) => K extends keyof T
    ? Chainable<Omit<T, K> & { [P in K]: V }>
    : Chainable<T & { [P in K]: V }>
  get: () => T
}

验证

declare const 
a
:
Chainable
const
result1
=
a
.
option
('foo', 123)
.
option
('bar', {
value
: 'Hello World' })
.
option
('name', 'type-challenges')
.
get
()
const
result2
=
a
.
option
('name', 'another name')
.
option
('name', 'last name')
.
get
()
const
result3
=
a
.
option
('name', 'another name')
// @ts-expect-error .
option
('name', 123)
.
get
()
type
cases
= [
Expect
<
Alike
<typeof
result1
,
Expected1
>>,
Expect
<
Alike
<typeof
result2
,
Expected2
>>,
Expect
<
Alike
<typeof
result3
,
Expected3
>>,
] type
Expected1
= {
foo
: number
bar
: {
value
: string
}
name
: string
} type
Expected2
= {
name
: string
} type
Expected3
= {
name
: number
}

参考