[TypeScript] Type Challenges #12 - Chainable Options

130 阅读3分钟

题目描述

在 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 只会被使用一次。

题解

// ============= Test Cases =============
import type { AlikeExpect } from './test-utils'

declare const aChainable

const result1 = a
  .option('foo'123)
  .option('bar', { value'Hello World' })
  .option('name''type-challenges')
  .get()

const result2 = a
  .option('name''another name')
  // @ts-expect-error
  .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 = {
  foonumber
  bar: {
    valuestring
  }
  namestring
}

type Expected2 = {
  namestring
}

type Expected3 = {
  namenumber
}


// ============= Your Code Here =============
type Chainable = {
  option(keystringvalueany): any
  get(): any
}

代码实现

下面是实现Chainable类型的过程:

初始代码

type Chainable = {
  option(keystringvalueany): any
  get(): any
}

这是初始的Chainable类型定义,它接受一个 key 和 value,并返回any类型。get方法也返回 any 类型

引入泛型

为了存储对象的当前状态,我们引入泛型T,get 方法也返回泛型T

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

引入泛型T后,出现了报错,报错信息Generic type 'Chainable' requires 1 type argument(s).

Xnip2025-01-05_18-07-07.jpg

我们需要给泛型T添加默认值

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

定义 key 和 value 的类型

使用KV泛型来表示键和值的类型,使用K extends PropertyKeyK进行约束,确保K是合法的键类型

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

实现链式调用

为了实现链式调用,option函数需要返回Chainable对象,并使用T & Record<K, V>将新的key、value合并到T

type Chainable<T = {}> = {
  option<
   K extends PropertyKey,
   V
  >(
    key: K,
    value: V
  ): Chainable<T & Record<KV>>
  get(): T
}

此时可以看到result1已测试通过

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

接着查看result2的错误提示,意思是不能设置重复的 key

const result2 = a
  .option('name''another name')
  // @ts-expect-error
  .option('name''last name')
  .get()

确保 key 不重复

为了确保 key 不重复,我们使用Exclude<K, keyof T>排除T中已存在的 key,避免在option函数中使用重复的 key,这样可以保证在类型层面上,不会将相同的 key 添加到对象中两次

type Chainable<T = {}> = {
  option<
   K extends PropertyKey,
   V
  >(
    key: Exclude<K, keyof T>, 
    value: V
  ): Chainable<T & Record<KV>>
  get(): T
}

此时可以看到result2已测试通过,但是result3还是测试失败

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

鼠标移到result3上查看result3的类型

const result3: {  
    name: string;  
} & {  
    name: number;  
}

预期的类型是{ name: number },所以只需要改成相同 key 时取最后一个 key 的类型

type Expected3 = {
  name: number
}

处理相同 key 时取最后一个 key 的类型

先使用Omit移除T中已有的K键,再使用 Record<K, V>添加新的K键及其值,这样最终结果会取最后一个键的类型

type Chainable<T = {}> = {
  option<
   K extends PropertyKey,
   V
  >(
    key: Exclude<K, keyof T>,
    value: V
  ): Chainable<Omit<TK> & Record<KV>>
  get(): T
}

这样,我们就完成了Chainable类型的实现