如何在TypeScript中编写受限身份函数(CIF)?

83 阅读7分钟

在《如何用TypeScript编写React组件》中,我打了一个React组件的例子。这里是我们结束的地方:

const operations = {
  '+': (left: number, right: number): number => left + right,
  '-': (left: number, right: number): number => left - right,
  '*': (left: number, right: number): number => left * right,
  '/': (left: number, right: number): number => left / right,
}

type CalculatorProps = {
  left: number
  operator: keyof typeof operations
  right: number
}

function Calculator({left, operator, right}: CalculatorProps) {
  const result = operations[operator](left, right)
  return (
    <div>
      <code>
        {left} {operator} {right} = <output>{result}</output>
      </code>
    </div>
  )
}

const examples = (
  <>
    <Calculator left={1} operator="+" right={2} />
    <Calculator left={1} operator="-" right={2} />
    <Calculator left={1} operator="*" right={2} />
    <Calculator left={1} operator="/" right={2} />
  </>
)

虽然我对operations 这个函数不满意。我知道该对象中的每个函数都会有完全相同的类型(由于用例的需要):

type OperationFn = (left: number, right: number) => number

operations 对象实际上只是一个操作字符串的记录,它被映射到一个对两个数字进行操作的函数。因此,如果我们在我们的operations 变量上添加一个类型注解,那么我们就不必对每个函数单独进行类型化。 让我们试试:

type OperationFn = (left: number, right: number) => number
const operations: Record<string, OperationFn> = {
  '+': (left, right) => left + right,
  '-': (left, right) => left - right,
  '*': (left, right) => left * right,
  '/': (left, right) => left / right,
}

type CalculatorProps = {
  left: number
  operator: keyof typeof operations
  right: number
}

很好,所以我们不必单独输入每个函数,但是,哦,不......现在typeof operationsRecord<string, OperationFn> ,其中的keyof 将是string ,这意味着我们的CalculatorProps['operator'] 类型将是string 。唉 😩

这里是我们可以做的,以解决这个问题:

type OperationFn = (left: number, right: number) => number
type Operator = '+' | '-' | '/' | '*'
const operations: Record<Operator, OperationFn> = {
  '+': (left, right) => left + right,
  '-': (left, right) => left - right,
  '*': (left, right) => left * right,
  '/': (left, right) => left / right,
}

type CalculatorProps = {
  left: number
  operator: keyof typeof operations
  right: number
}

但是现在我们又不得不在两个地方添加** ,如果我们决定添加指数运算符。然而,在这种情况下,如果我们在一个地方而不是另一个地方添加它,TypeScript会给我们一个编译器错误,所以这是个进步。

这是我第一次写这个组件时留下的痕迹,但后来@AlekseyL13建议我尝试使用正确类型的身份函数。

受约束的身份函数

首先,让我们牢记,我们有两个目标:

  1. 确保每个属性的类型是相同的(在这个简单的例子中,它只是一个数字,但在我们的实际例子中,它是一个函数类型
  2. 确保我们的对象的keyof typeof ,结果是键的有限联合。

在TypeScript中,要同时拥有这两点是个挑战。默认情况下,我们会得到第二个目标。问题是,当你试图用像const operations: Record<string, OperationFn> = ... 这样的类型注解来完成第一个目标时,你最终会扩大key ,所以keyof typeof 的结果是string 。唉,真烦人。

所以这就是约束性身份函数的用武之地。顺便说一下,"受限 "描述了一种情况,即你有一个函数接受比它所传递的输入更窄的版本。

type NamedObject = {name: string}
function getUserName<User extends NamedObject>(user: User) {
  return user.name
}

const obj = {name: 'Hannah', age: 3}
getUserName(obj)

因此,传递给getUserName 的对象必须满足NamedObject 中的所有类型。getUserName 对输入进行约束,使其至少符合该类型。

而 "身份函数 "是一个接受一个值并返回该值的函数。我有时会使用这类函数作为回调的默认值。

const identity = <Type extends unknown>(item: Type) => item

type ModifyConfigFn = (config: ConfigType) => ConfigType
function buildProject(modifyConfig: ModifyConfigFn = identity) {
  const config: ConfigType = {
    /* some config */
  }
  const modifiedConfig = modifyConfig(config)
  // more stuff...
}

因此,有了这些定义,"受限的身份函数 "是一个函数,它返回它所给的东西,并帮助TypeScript限制其类型。这正是我们想要做的。

我们可以把它叫做CIF(发音为 "seek eye eff")。当然,我们就用这个吧。

让我先给你看一个简单的例子,然后我再解释一下是怎么回事,然后我们可以把它更有效地应用到我们更复杂的例子中。

type Value = number
const createNumbers = <ObjectType extends Record<string, Value>>(
  obj: ObjectType,
) => obj

const numbers = createNumbers({one: 1, two: 2, three: 3, four: 4})

// @ts-expect-error we don't have 'five' yet
numbers['five']

因此,createNumbers 是约束性的身份函数。它返回给定的obj ,希望这很清楚。但它是如何执行我们的输入并约束类型的呢?

让我这样解释。如果我们从。

const numbers = {one: 1, two: 2, three: 3, four: 4}
// typeof numbers:
// {
//   one: number;
//   two: number;
//   three: number;
//   four: number;
// }

但在未来,有人可能会来到这段代码,并像这样改变它。

const numbers = {one: 1, two: 2, three: 3, four: 4, five: '5'}
// typeof numbers:
// {
//   one: number;
//   two: number;
//   three: number;
//   four: number;
//   five: string; // 😱
// }

Yikes!不,我们不能有这种情况!(而且,更重要的是,在我们的Calculator例子中,对函数进行一些自动打字是这里的目标)。

所以,让我们用一个类型注解来强制执行我们的值类型。

// @ts-expect-error HA! We gotcha! No strings in this object!
const numbers: Record<string, number> = {
  one: 1,
  two: 2,
  three: 3,
  four: 4,
  five: '5',
}

但是现在通过明确地输入我们的值,我们已经告诉TypeScript,我们的key可以是一个字符串。不幸的是,我们没有办法告诉TypeScript。"这个东西有它的键,但值是这个特定的类型。"IMO,这是TypeScript缺失的一个功能。我们的createNumbers 约束身份函数(呃... "CIF")是一种变通方法。

所以,这就是那个变通的方法。

受限身份函数允许我们明确地注释我们的变量,同时仍然可以强制执行值。

因此,我们创建对象,获得TypeScript可以提供给我们的最佳类型(包括窄键和宽值),然后我们把它传递给一个接受宽键和窄值的函数。TypeScript将这些结合起来,给了我们一个Record ,其中的keyvalue 都是狭义的!

好了,让我们把CIF应用到我们原来的情况。

type OperationFn = (left: number, right: number) => number
const createOperations = <OperationsType extends Record<string, OperationFn>>(
  operations: OperationsType,
) => operations

const operations = createOperations({
  '+': (left, right) => left + right,
  '-': (left, right) => left - right,
  '*': (left, right) => left * right,
  '/': (left, right) => left / right,
})

type CalculatorProps = {
  left: number
  operator: keyof typeof operations
  right: number
}

// @ts-expect-error we haven't added support
// for the exponentiation operator yet
operations['**'](1, 2)

Wahoo!所以有了这个解决方案,我们不需要明确地以完全相同的方式输入所有的操作函数,我们仍然可以得到一个所有可用操作的联合类型。

一个通用的CIF?

你可能已经注意到,我们在上一节中有两个CIF。

type Value = number
const createNumbers = <ObjectType extends Record<string, Value>>(
  obj: ObjectType,
) => obj

type OperationFn = (left: number, right: number) => number
const createOperations = <OperationsType extends Record<string, OperationFn>>(
  operations: OperationsType,
) => operations

如果我们能把这些结合起来,那不是很好吗?当然会。但你不会喜欢它...这是我首先尝试的。

const constrain = <Given, Inferred extends Given>(item: Inferred) => item

// @ts-expect-error Expected 2 type arguments, but got 1.(2558)
const numbers = constrain<Record<string, number>>({one: 1 /* etc. */})

悲哀的一天。不幸的是,这在今天的TypeScript中是不可能的。但这里有一个变通办法。

const constrain =
  <Given extends unknown>() =>
  <Inferred extends Given>(item: Inferred) =>
    item

const numbers = constrain<Record<string, number>>()({one: 1 /* etc. */})

......是的,我告诉你你不会喜欢它。这样做稍微好一点。

const createNumbers = constrain<Record<string, number>>()
const numbers = createNumbers({one: 1 /* etc. */})

但就像,呵呵。讨厌。

幸运的是,我发现自己并不经常做CIF,而且它们并不难写,所以我不需要一个抽象的东西。不过我觉得和大家分享一下还是很有意思的 😄

总结

这是我们的计算器组件的最终版本,所有的东西都用我们的CIF打出来了。

type OperationFn = (left: number, right: number) => number
const createOperations = <OperationsType extends Record<string, OperationFn>>(
  opts: OperationsType,
) => opts

const operations = createOperations({
  '+': (left, right) => left + right,
  '-': (left, right) => left - right,
  '*': (left, right) => left * right,
  '/': (left, right) => left / right,
})

type CalculatorProps = {
  left: number
  operator: keyof typeof operations
  right: number
}
function Calculator({left, operator, right}: CalculatorProps) {
  const result = operations[operator](left, right)
  return (
    <div>
      <code>
        {left} {operator} {right} = <output>{result}</output>
      </code>
    </div>
  )
}

const examples = (
  <>
    <Calculator left={1} operator="+" right={2} />
    <Calculator left={1} operator="-" right={2} />
    <Calculator left={1} operator="*" right={2} />
    <Calculator left={1} operator="/" right={2} />
  </>
)

是的,写这篇博文就是为了向你解释这三行代码。所以,是的,你去吧。

有些人读完这篇文章后可能会嗤之以鼻,说什么。"如果TypeScript要求你做这样奇怪的事情,你为什么还要使用它?"

首先,我想说的是,像TypeScript这样的工具需要解决一些类似的问题,并不意味着它不值得使用。这里的成本是最小的,好处是很大的。我不是来说服你使用TypeScript的,我做不到像你的运行时错误那样说服你,我相信😜其次,这绝对是TypeScript未来可以改进的地方。事实上,这可能是改善事情的一个很好的步骤。 最后,就像我说的,这并不是我们一直在做的事情。我与TypeScript的大部分时间都是愉快的。

照顾好自己!